From 8f2b3f8ed217e0ef13f107cd5a7452d9daa8126f Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 25 Jul 2024 20:22:28 +0800 Subject: [PATCH 01/19] Clean up Empty method signature arg in error message --- packages/syft/src/syft/service/service.py | 71 +++++++++++++++++++---- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/syft/src/syft/service/service.py b/packages/syft/src/syft/service/service.py index 76a61689eaa..8449531ffdd 100644 --- a/packages/syft/src/syft/service/service.py +++ b/packages/syft/src/syft/service/service.py @@ -4,16 +4,22 @@ # stdlib from collections import defaultdict from collections.abc import Callable +from collections.abc import Iterable from copy import deepcopy import functools from functools import partial +from functools import reduce import inspect from inspect import Parameter import logging +import operator +import types +import typing from typing import Any from typing import TYPE_CHECKING # third party +from pydantic import ValidationError from result import Ok from result import OkErr from typing_extensions import Self @@ -34,6 +40,8 @@ from ..server.credentials import SyftVerifyKey from ..store.document_store import DocumentStore from ..store.linked_obj import LinkedObject +from ..types.syft_metaclass import Empty +from ..types.syft_metaclass import EmptyType from ..types.syft_object import SYFT_OBJECT_VERSION_1 from ..types.syft_object import SyftBaseObject from ..types.syft_object import SyftObject @@ -260,16 +268,47 @@ def deconstruct_param(param: inspect.Parameter) -> dict[str, Any]: def types_for_autosplat(signature: Signature, autosplat: list[str]) -> dict[str, type]: - autosplat_types = {} - for k, v in signature.parameters.items(): - if k in autosplat: - autosplat_types[k] = v.annotation - return autosplat_types + return {k: v.annotation for k, v in signature.parameters.items() if k in autosplat} + + +def _check_empty_union(x: Any) -> bool: + return isinstance( + x, typing._UnionGenericAlias | types.UnionType + ) and EmptyType in typing.get_args(x) + + +def _check_empty_parameter(p: Parameter) -> bool: + return _check_empty_union(p.annotation) and p.default is Empty + + +def _make_union_type(args: Iterable) -> types.UnionType: + return reduce(operator.or_, args) + + +def _replace_empty_parameter(p: Parameter) -> Parameter: + return Parameter( + name=p.name, + default="optional", + annotation=_make_union_type( + t for t in typing.get_args(p.annotation) if t is not EmptyType + ), + kind=p.kind, + ) + + +def _filter_empty(s: inspect.Signature) -> inspect.Signature: + params = ( + (_replace_empty_parameter(p) if _check_empty_parameter(p) else p) + for p in s.parameters.values() + ) + + return inspect.Signature(parameters=params, return_annotation=s.return_annotation) def reconstruct_args_kwargs( signature: Signature, autosplat: list[str], + expanded_signature: Signature, args: tuple[Any, ...], kwargs: dict[Any, str], ) -> tuple[tuple[Any, ...], dict[str, Any]]: @@ -282,7 +321,14 @@ def reconstruct_args_kwargs( for key in keys: if key in kwargs: init_kwargs[key] = kwargs.pop(key) - autosplat_objs[autosplat_key] = autosplat_type(**init_kwargs) + try: + autosplat_objs[autosplat_key] = autosplat_type(**init_kwargs) + except ValidationError: + raise TypeError( + f"Invalid argument(s) provided. " + f"Please provide the correct arguments to the method " + f"according to this signature {_filter_empty(expanded_signature)}." + ) final_kwargs = {} for param_key, param in signature.parameters.items(): @@ -293,7 +339,11 @@ def reconstruct_args_kwargs( elif not isinstance(param.default, type(Parameter.empty)): final_kwargs[param_key] = param.default else: - raise Exception(f"Missing {param_key} not in kwargs.") + raise TypeError( + f"Missing argument {param_key}." + f"Please provide the correct arguments to the method " + f"according to this signature {_filter_empty(expanded_signature)}." + ) if "context" in kwargs: final_kwargs["context"] = kwargs["context"] @@ -354,6 +404,9 @@ def wrapper(func: Any) -> Callable: input_signature = deepcopy(signature) + if autosplat is not None and len(autosplat) > 0: + signature = expand_signature(signature=input_signature, autosplat=autosplat) + @functools.wraps(func) def _decorator(self: Any, *args: Any, **kwargs: Any) -> Callable: communication_protocol = kwargs.pop("communication_protocol", None) @@ -366,6 +419,7 @@ def _decorator(self: Any, *args: Any, **kwargs: Any) -> Callable: args, kwargs = reconstruct_args_kwargs( signature=input_signature, autosplat=autosplat, + expanded_signature=signature, args=args, kwargs=kwargs, ) @@ -386,9 +440,6 @@ def _decorator(self: Any, *args: Any, **kwargs: Any) -> Callable: attach_attribute_to_syft_object(result=result, attr_dict=attrs_to_attach) return result - if autosplat is not None and len(autosplat) > 0: - signature = expand_signature(signature=input_signature, autosplat=autosplat) - config = ServiceConfig( public_path=_path if path is None else path, private_path=_path, From bce90d8b63f27dc4f5ee6551b2a285e259f67033 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 25 Jul 2024 20:32:23 +0800 Subject: [PATCH 02/19] Show method signature in error message when invalid args and kwargs are passed to service method --- packages/syft/src/syft/client/api.py | 18 ++++++++++++++++-- packages/syft/src/syft/service/service.py | 13 +++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/syft/src/syft/client/api.py b/packages/syft/src/syft/client/api.py index 0ff9299bdfe..c728af1eeac 100644 --- a/packages/syft/src/syft/client/api.py +++ b/packages/syft/src/syft/client/api.py @@ -1302,8 +1302,15 @@ def validate_callable_args_and_kwargs( else: for key, value in kwargs.items(): if key not in signature.parameters: + # relative + from ..service.service import _filter_empty + from ..service.service import _signature_error_message + return SyftError( - message=f"""Invalid parameter: `{key}`. Valid Parameters: {list(signature.parameters)}""" + message=( + f"Invalid parameter: `{key}`. " + f"{_signature_error_message(_filter_empty(signature))}" + ) ) param = signature.parameters[key] if isinstance(param.annotation, str): @@ -1367,7 +1374,14 @@ def validate_callable_args_and_kwargs( pass else: _type_str = getattr(t, "__name__", str(t)) - msg = f"Arg is `{arg}`. \nIt must be of type `{_type_str}`, not `{type(arg).__name__}`" + # relative + from ..service.service import _filter_empty + from ..service.service import _signature_error_message + + msg = ( + f"Arg is `{arg}`. \nIt must be of type `{_type_str}`, not `{type(arg).__name__}`" + f"{_signature_error_message(_filter_empty(signature))}" + ) if msg: return SyftError(message=msg) diff --git a/packages/syft/src/syft/service/service.py b/packages/syft/src/syft/service/service.py index 8449531ffdd..c828302e28a 100644 --- a/packages/syft/src/syft/service/service.py +++ b/packages/syft/src/syft/service/service.py @@ -305,6 +305,13 @@ def _filter_empty(s: inspect.Signature) -> inspect.Signature: return inspect.Signature(parameters=params, return_annotation=s.return_annotation) +def _signature_error_message(s: inspect.Signature) -> str: + return ( + f"Please provide the correct arguments to the method " + f"according to this signature {s}." + ) + + def reconstruct_args_kwargs( signature: Signature, autosplat: list[str], @@ -326,8 +333,7 @@ def reconstruct_args_kwargs( except ValidationError: raise TypeError( f"Invalid argument(s) provided. " - f"Please provide the correct arguments to the method " - f"according to this signature {_filter_empty(expanded_signature)}." + f"{_signature_error_message(_filter_empty(expanded_signature))}" ) final_kwargs = {} @@ -341,8 +347,7 @@ def reconstruct_args_kwargs( else: raise TypeError( f"Missing argument {param_key}." - f"Please provide the correct arguments to the method " - f"according to this signature {_filter_empty(expanded_signature)}." + f"{_signature_error_message(_filter_empty(expanded_signature))}" ) if "context" in kwargs: From c172aec01a9b8b1f90f44ef525f246fd15d391d7 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 25 Jul 2024 20:35:23 +0800 Subject: [PATCH 03/19] Omit return annotation in error message --- packages/syft/src/syft/client/api.py | 8 ++++---- packages/syft/src/syft/service/service.py | 11 +++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/syft/src/syft/client/api.py b/packages/syft/src/syft/client/api.py index c728af1eeac..0ef3d6d410c 100644 --- a/packages/syft/src/syft/client/api.py +++ b/packages/syft/src/syft/client/api.py @@ -1303,13 +1303,13 @@ def validate_callable_args_and_kwargs( for key, value in kwargs.items(): if key not in signature.parameters: # relative - from ..service.service import _filter_empty + from ..service.service import _format_signature from ..service.service import _signature_error_message return SyftError( message=( f"Invalid parameter: `{key}`. " - f"{_signature_error_message(_filter_empty(signature))}" + f"{_signature_error_message(_format_signature(signature))}" ) ) param = signature.parameters[key] @@ -1375,12 +1375,12 @@ def validate_callable_args_and_kwargs( else: _type_str = getattr(t, "__name__", str(t)) # relative - from ..service.service import _filter_empty + from ..service.service import _format_signature from ..service.service import _signature_error_message msg = ( f"Arg is `{arg}`. \nIt must be of type `{_type_str}`, not `{type(arg).__name__}`" - f"{_signature_error_message(_filter_empty(signature))}" + f"{_signature_error_message(_format_signature(signature))}" ) if msg: diff --git a/packages/syft/src/syft/service/service.py b/packages/syft/src/syft/service/service.py index c828302e28a..c5e9a2c51a7 100644 --- a/packages/syft/src/syft/service/service.py +++ b/packages/syft/src/syft/service/service.py @@ -296,13 +296,16 @@ def _replace_empty_parameter(p: Parameter) -> Parameter: ) -def _filter_empty(s: inspect.Signature) -> inspect.Signature: +def _format_signature(s: inspect.Signature) -> inspect.Signature: params = ( (_replace_empty_parameter(p) if _check_empty_parameter(p) else p) for p in s.parameters.values() ) - return inspect.Signature(parameters=params, return_annotation=s.return_annotation) + return inspect.Signature( + parameters=params, + return_annotation=inspect.Signature.empty, + ) def _signature_error_message(s: inspect.Signature) -> str: @@ -333,7 +336,7 @@ def reconstruct_args_kwargs( except ValidationError: raise TypeError( f"Invalid argument(s) provided. " - f"{_signature_error_message(_filter_empty(expanded_signature))}" + f"{_signature_error_message(_format_signature(expanded_signature))}" ) final_kwargs = {} @@ -347,7 +350,7 @@ def reconstruct_args_kwargs( else: raise TypeError( f"Missing argument {param_key}." - f"{_signature_error_message(_filter_empty(expanded_signature))}" + f"{_signature_error_message(_format_signature(expanded_signature))}" ) if "context" in kwargs: From 64d41306942ecc0ed502c0a7d7ff0dfff4243c17 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 25 Jul 2024 20:38:55 +0800 Subject: [PATCH 04/19] Format --- packages/syft/src/syft/client/api.py | 13 ++++++++++--- packages/syft/src/syft/service/service.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/syft/src/syft/client/api.py b/packages/syft/src/syft/client/api.py index 0ef3d6d410c..1923475b62e 100644 --- a/packages/syft/src/syft/client/api.py +++ b/packages/syft/src/syft/client/api.py @@ -1308,7 +1308,7 @@ def validate_callable_args_and_kwargs( return SyftError( message=( - f"Invalid parameter: `{key}`. " + f"Invalid parameter: `{key}`.\n" f"{_signature_error_message(_format_signature(signature))}" ) ) @@ -1335,8 +1335,15 @@ def validate_callable_args_and_kwargs( TypeAdapter(t, **config_kw).validate_python(value) except Exception: _type_str = getattr(t, "__name__", str(t)) + # relative + from ..service.service import _format_signature + from ..service.service import _signature_error_message + return SyftError( - message=f"`{key}` must be of type `{_type_str}` not `{type(value).__name__}`" + message=( + f"`{key}` must be of type `{_type_str}` not `{type(value).__name__}`\n" + f"{_signature_error_message(_format_signature(signature))}" + ) ) _valid_kwargs[key] = value @@ -1379,7 +1386,7 @@ def validate_callable_args_and_kwargs( from ..service.service import _signature_error_message msg = ( - f"Arg is `{arg}`. \nIt must be of type `{_type_str}`, not `{type(arg).__name__}`" + f"Arg is `{arg}`. \nIt must be of type `{_type_str}`, not `{type(arg).__name__}`\n" f"{_signature_error_message(_format_signature(signature))}" ) diff --git a/packages/syft/src/syft/service/service.py b/packages/syft/src/syft/service/service.py index c5e9a2c51a7..53096cc32b7 100644 --- a/packages/syft/src/syft/service/service.py +++ b/packages/syft/src/syft/service/service.py @@ -311,7 +311,7 @@ def _format_signature(s: inspect.Signature) -> inspect.Signature: def _signature_error_message(s: inspect.Signature) -> str: return ( f"Please provide the correct arguments to the method " - f"according to this signature {s}." + f"according to this signature\n{s}." ) From 1172c2f94e1600aaaa5e7167ef30eafebd603f02 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 25 Jul 2024 20:46:11 +0800 Subject: [PATCH 05/19] Exclude some internal attributes from method signature --- packages/syft/src/syft/service/service.py | 3 ++- packages/syft/src/syft/types/syft_object.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/syft/src/syft/service/service.py b/packages/syft/src/syft/service/service.py index 53096cc32b7..6a609b2bd02 100644 --- a/packages/syft/src/syft/service/service.py +++ b/packages/syft/src/syft/service/service.py @@ -42,6 +42,7 @@ from ..store.linked_obj import LinkedObject from ..types.syft_metaclass import Empty from ..types.syft_metaclass import EmptyType +from ..types.syft_object import EXCLUDED_FROM_SIGNATURE from ..types.syft_object import SYFT_OBJECT_VERSION_1 from ..types.syft_object import SyftBaseObject from ..types.syft_object import SyftObject @@ -378,7 +379,7 @@ def expand_signature(signature: Signature, autosplat: list[str]) -> Signature: # Reorder the parameter based on if they have default value or not new_params = sorted( - new_mapping.values(), + (v for k, v in new_mapping.items() if k not in EXCLUDED_FROM_SIGNATURE), key=lambda param: param.default is param.empty, reverse=True, ) diff --git a/packages/syft/src/syft/types/syft_object.py b/packages/syft/src/syft/types/syft_object.py index f987479cafc..97b70163ee3 100644 --- a/packages/syft/src/syft/types/syft_object.py +++ b/packages/syft/src/syft/types/syft_object.py @@ -346,6 +346,11 @@ def __lt__(self, other: Self) -> bool: return self.utc_timestamp < other.utc_timestamp +EXCLUDED_FROM_SIGNATURE = set( + DYNAMIC_SYFT_ATTRIBUTES + ["created_date", "updated_date", "deleted_date"] +) + + @serializable() class SyftObject(SyftObjectVersioned): __canonical_name__ = "SyftObject" From 3cb173a5452a070ccbb224b1420034a63d3dec33 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 25 Jul 2024 21:18:44 +0800 Subject: [PATCH 06/19] Exclude internal attributes from error messages only --- packages/syft/src/syft/service/service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/syft/src/syft/service/service.py b/packages/syft/src/syft/service/service.py index 6a609b2bd02..cdad7c1ef5c 100644 --- a/packages/syft/src/syft/service/service.py +++ b/packages/syft/src/syft/service/service.py @@ -300,7 +300,8 @@ def _replace_empty_parameter(p: Parameter) -> Parameter: def _format_signature(s: inspect.Signature) -> inspect.Signature: params = ( (_replace_empty_parameter(p) if _check_empty_parameter(p) else p) - for p in s.parameters.values() + for k, p in s.parameters.items() + if k not in EXCLUDED_FROM_SIGNATURE ) return inspect.Signature( @@ -379,7 +380,7 @@ def expand_signature(signature: Signature, autosplat: list[str]) -> Signature: # Reorder the parameter based on if they have default value or not new_params = sorted( - (v for k, v in new_mapping.items() if k not in EXCLUDED_FROM_SIGNATURE), + new_mapping.values(), key=lambda param: param.default is param.empty, reverse=True, ) From cd49381f5973eef61ae6d9cc0d57a345e18e649e Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Mon, 29 Jul 2024 14:33:28 +0800 Subject: [PATCH 07/19] Revert "Exclude internal attributes from error messages only" This reverts commit 5a56d0a4cfa08f05db95a02ddf86eecfba360f29. --- packages/syft/src/syft/service/service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/syft/src/syft/service/service.py b/packages/syft/src/syft/service/service.py index cdad7c1ef5c..6a609b2bd02 100644 --- a/packages/syft/src/syft/service/service.py +++ b/packages/syft/src/syft/service/service.py @@ -300,8 +300,7 @@ def _replace_empty_parameter(p: Parameter) -> Parameter: def _format_signature(s: inspect.Signature) -> inspect.Signature: params = ( (_replace_empty_parameter(p) if _check_empty_parameter(p) else p) - for k, p in s.parameters.items() - if k not in EXCLUDED_FROM_SIGNATURE + for p in s.parameters.values() ) return inspect.Signature( @@ -380,7 +379,7 @@ def expand_signature(signature: Signature, autosplat: list[str]) -> Signature: # Reorder the parameter based on if they have default value or not new_params = sorted( - new_mapping.values(), + (v for k, v in new_mapping.items() if k not in EXCLUDED_FROM_SIGNATURE), key=lambda param: param.default is param.empty, reverse=True, ) From 283c72d3af853ecc1a1b2d89eb9cb5ca40c202ea Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Mon, 29 Jul 2024 16:39:23 +0800 Subject: [PATCH 08/19] Use autosplat in tests --- .../syft/settings/settings_service_test.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/syft/tests/syft/settings/settings_service_test.py b/packages/syft/tests/syft/settings/settings_service_test.py index 3ef9178837b..78b6ab7d9b1 100644 --- a/packages/syft/tests/syft/settings/settings_service_test.py +++ b/packages/syft/tests/syft/settings/settings_service_test.py @@ -2,6 +2,7 @@ from copy import deepcopy from datetime import datetime from unittest import mock +from uuid import uuid4 # third party from faker import Faker @@ -23,7 +24,6 @@ from syft.service.settings.settings import ServerSettingsUpdate from syft.service.settings.settings_service import SettingsService from syft.service.settings.settings_stash import SettingsStash -from syft.service.user.user import UserCreate from syft.service.user.user_roles import ServiceRole @@ -339,21 +339,22 @@ def test_settings_allow_guest_registration( def test_user_register_for_role(monkeypatch: MonkeyPatch, faker: Faker): # Mock patch this env variable to remove race conditions # where signup is enabled. + def get_mock_client(faker, root_client, role): - user_create = UserCreate( + email = faker.email() + password = uuid4().hex + + result = root_client.users.create( name=faker.name(), - email=faker.email(), + email=email, role=role, - password="password", - password_verify="password", + password=password, + password_verify=password, ) - result = root_client.users.create(**user_create) assert not isinstance(result, SyftError) guest_client = root_client.guest() - return guest_client.login( - email=user_create.email, password=user_create.password - ) + return guest_client.login(email=email, password=password) verify_key = SyftSigningKey.generate().verify_key mock_server_settings = ServerSettings( From b1cc76b7d5b024d509d2c99c741ac90d7e591f89 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Tue, 30 Jul 2024 15:31:29 +0800 Subject: [PATCH 09/19] Use pydantic validation instead of typeguard.check_type --- packages/syft/src/syft/client/api.py | 46 +++++++++---------- .../src/syft/service/action/action_object.py | 32 +++++++------ 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/packages/syft/src/syft/client/api.py b/packages/syft/src/syft/client/api.py index 1923475b62e..e7628ccdc3d 100644 --- a/packages/syft/src/syft/client/api.py +++ b/packages/syft/src/syft/client/api.py @@ -10,7 +10,6 @@ import types from typing import Any from typing import TYPE_CHECKING -from typing import _GenericAlias from typing import cast from typing import get_args from typing import get_origin @@ -19,12 +18,9 @@ from nacl.exceptions import BadSignatureError from pydantic import BaseModel from pydantic import ConfigDict -from pydantic import EmailStr from pydantic import TypeAdapter from result import OkErr from result import Result -from typeguard import TypeCheckError -from typeguard import check_type # relative from ..abstract_server import AbstractServer @@ -97,6 +93,23 @@ def _has_config_dict(t: Any) -> bool: ) +_config_dict = ConfigDict(arbitrary_types_allowed=True) + + +def _check_type(v: object, t: Any) -> Any: + # TypeAdapter only accepts `config` arg if `t` does not + # already contain a ConfigDict + # i.e model_config in BaseModel and __pydantic_config__ in + # other types. + type_adapter = ( + TypeAdapter(t, config=_config_dict) + if not _has_config_dict(t) + else TypeAdapter(t) + ) + + return type_adapter.validate_python(v) + + class APIRegistry: __api_registry__: dict[tuple, SyftAPI] = OrderedDict() @@ -1322,18 +1335,8 @@ def validate_callable_args_and_kwargs( if t is not inspect.Parameter.empty: try: - config_kw = ( - {"config": ConfigDict(arbitrary_types_allowed=True)} - if not _has_config_dict(t) - else {} - ) - - # TypeAdapter only accepts `config` arg if `t` does not - # already contain a ConfigDict - # i.e model_config in BaseModel and __pydantic_config__ in - # other types. - TypeAdapter(t, **config_kw).validate_python(value) - except Exception: + _check_type(value, t) + except ValueError: _type_str = getattr(t, "__name__", str(t)) # relative from ..service.service import _format_signature @@ -1362,15 +1365,8 @@ def validate_callable_args_and_kwargs( msg = None try: if t is not inspect.Parameter.empty: - if isinstance(t, _GenericAlias) and type(None) in t.__args__: - for v in t.__args__: - if issubclass(v, EmailStr): - v = str - check_type(arg, v) # raises Exception - break # only need one to match - else: - check_type(arg, t) # raises Exception - except TypeCheckError: + _check_type(arg, t) + except ValueError: t_arg = type(arg) if ( autoreload_enabled() diff --git a/packages/syft/src/syft/service/action/action_object.py b/packages/syft/src/syft/service/action/action_object.py index bbad29396b9..955ceb8637e 100644 --- a/packages/syft/src/syft/service/action/action_object.py +++ b/packages/syft/src/syft/service/action/action_object.py @@ -913,21 +913,23 @@ def syft_lineage_id(self) -> LineageID: @model_validator(mode="before") @classmethod - def __check_action_data(cls, values: dict) -> dict: - v = values.get("syft_action_data_cache") - if values.get("syft_action_data_type", None) is None: - values["syft_action_data_type"] = type(v) - if not isinstance(v, ActionDataEmpty): - if inspect.isclass(v): - values["syft_action_data_repr_"] = truncate_str(repr_cls(v)) - else: - values["syft_action_data_repr_"] = truncate_str( - v._repr_markdown_() - if v is not None and hasattr(v, "_repr_markdown_") - else v.__repr__() - ) - values["syft_action_data_str_"] = truncate_str(str(v)) - values["syft_has_bool_attr"] = hasattr(v, "__bool__") + def __check_action_data(cls, values: Any) -> dict: + if isinstance(values, dict): + v = values.get("syft_action_data_cache") + if values.get("syft_action_data_type", None) is None: + values["syft_action_data_type"] = type(v) + if not isinstance(v, ActionDataEmpty): + if inspect.isclass(v): + values["syft_action_data_repr_"] = truncate_str(repr_cls(v)) + else: + values["syft_action_data_repr_"] = truncate_str( + v._repr_markdown_() + if v is not None and hasattr(v, "_repr_markdown_") + else v.__repr__() + ) + values["syft_action_data_str_"] = truncate_str(str(v)) + values["syft_has_bool_attr"] = hasattr(v, "__bool__") + return values @property From a19d477299c97c7322f914f776d13227a7566090 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Tue, 30 Jul 2024 17:37:39 +0800 Subject: [PATCH 10/19] Put UserUpdate.role validator in Annotated instead of @field_validator so that _check_type for UserUpdate.role accepts both ServiceRole and str --- packages/syft/src/syft/service/user/user.py | 41 +++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/syft/src/syft/service/user/user.py b/packages/syft/src/syft/service/user/user.py index 383ed3c5f6a..e7f7b48de33 100644 --- a/packages/syft/src/syft/service/user/user.py +++ b/packages/syft/src/syft/service/user/user.py @@ -1,15 +1,16 @@ # stdlib from collections.abc import Callable from getpass import getpass +from typing import Annotated from typing import Any # third party from bcrypt import checkpw from bcrypt import gensalt from bcrypt import hashpw +from pydantic import BeforeValidator from pydantic import EmailStr from pydantic import ValidationError -from pydantic import field_validator # relative from ...client.api import APIRegistry @@ -19,6 +20,7 @@ from ...types.syft_metaclass import Empty from ...types.syft_object import PartialSyftObject from ...types.syft_object import SYFT_OBJECT_VERSION_1 +from ...types.syft_object import SYFT_OBJECT_VERSION_2 from ...types.syft_object import SyftObject from ...types.transforms import TransformContext from ...types.transforms import drop @@ -112,21 +114,37 @@ def check_pwd(password: str, hashed_password: str) -> bool: ) +def _str_to_role(v: Any) -> Any: + if isinstance(v, str) and hasattr(ServiceRole, v.upper()): + return getattr(ServiceRole, v.upper()) + return v + + @serializable() class UserUpdate(PartialSyftObject): __canonical_name__ = "UserUpdate" - __version__ = SYFT_OBJECT_VERSION_1 + __version__ = SYFT_OBJECT_VERSION_2 + + email: EmailStr + name: str + # make sure role cant be set without uid + role: Annotated[ServiceRole, BeforeValidator(_str_to_role)] + password: str + password_verify: str + verify_key: SyftVerifyKey + institution: str + website: str + mock_execution_permission: bool - @field_validator("role", mode="before") - @classmethod - def str_to_role(cls, v: Any) -> Any: - if isinstance(v, str) and hasattr(ServiceRole, v.upper()): - return getattr(ServiceRole, v.upper()) - return v + +@serializable() +class UserUpdateV1(PartialSyftObject): + __canonical_name__ = "UserUpdate" + __version__ = SYFT_OBJECT_VERSION_1 email: EmailStr name: str - role: ServiceRole # make sure role cant be set without uid + role: ServiceRole password: str password_verify: str verify_key: SyftVerifyKey @@ -273,14 +291,15 @@ def update( ) if api is None: return SyftError(message=f"You must login to {self.server_uid}") - user_update = UserUpdate( + + result = api.services.user.update( + uid=self.id, name=name, institution=institution, website=website, role=role, mock_execution_permission=mock_execution_permission, ) - result = api.services.user.update(uid=self.id, **user_update) if isinstance(result, SyftError): return result From ae462751f29aab8aa1ca5fed82db588853d2ab7d Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Tue, 30 Jul 2024 17:39:38 +0800 Subject: [PATCH 11/19] Use autosplat for user update --- packages/syft/tests/syft/service_permission_test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/syft/tests/syft/service_permission_test.py b/packages/syft/tests/syft/service_permission_test.py index a0476d1ff9d..0db6993e5f5 100644 --- a/packages/syft/tests/syft/service_permission_test.py +++ b/packages/syft/tests/syft/service_permission_test.py @@ -4,6 +4,7 @@ # syft absolute from syft import SyftError from syft.client.api import SyftAPICall +from syft.types.syft_object import EXCLUDED_FROM_SIGNATURE @pytest.fixture @@ -19,7 +20,10 @@ def guest_mock_user(root_verify_key, user_stash, guest_user): def test_call_service_syftapi_with_permission(worker, guest_mock_user, update_user): user_id = guest_mock_user.id - res = worker.root_client.api.services.user.update(uid=user_id, **update_user) + res = worker.root_client.api.services.user.update( + uid=user_id, + **{k: v for k, v in update_user if k not in EXCLUDED_FROM_SIGNATURE}, + ) assert res From 1bb5b0c1102c78f39af24e51f59862bec07b3b44 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Wed, 31 Jul 2024 17:06:43 +0800 Subject: [PATCH 12/19] Put pydantic validator for ServiceRole in the class itself --- packages/syft/src/syft/service/user/user.py | 28 +------------------ .../syft/src/syft/service/user/user_roles.py | 22 +++++++++++++++ 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/packages/syft/src/syft/service/user/user.py b/packages/syft/src/syft/service/user/user.py index e7f7b48de33..41c1ba56188 100644 --- a/packages/syft/src/syft/service/user/user.py +++ b/packages/syft/src/syft/service/user/user.py @@ -1,14 +1,12 @@ # stdlib from collections.abc import Callable from getpass import getpass -from typing import Annotated from typing import Any # third party from bcrypt import checkpw from bcrypt import gensalt from bcrypt import hashpw -from pydantic import BeforeValidator from pydantic import EmailStr from pydantic import ValidationError @@ -20,7 +18,6 @@ from ...types.syft_metaclass import Empty from ...types.syft_object import PartialSyftObject from ...types.syft_object import SYFT_OBJECT_VERSION_1 -from ...types.syft_object import SYFT_OBJECT_VERSION_2 from ...types.syft_object import SyftObject from ...types.transforms import TransformContext from ...types.transforms import drop @@ -114,37 +111,14 @@ def check_pwd(password: str, hashed_password: str) -> bool: ) -def _str_to_role(v: Any) -> Any: - if isinstance(v, str) and hasattr(ServiceRole, v.upper()): - return getattr(ServiceRole, v.upper()) - return v - - @serializable() class UserUpdate(PartialSyftObject): - __canonical_name__ = "UserUpdate" - __version__ = SYFT_OBJECT_VERSION_2 - - email: EmailStr - name: str - # make sure role cant be set without uid - role: Annotated[ServiceRole, BeforeValidator(_str_to_role)] - password: str - password_verify: str - verify_key: SyftVerifyKey - institution: str - website: str - mock_execution_permission: bool - - -@serializable() -class UserUpdateV1(PartialSyftObject): __canonical_name__ = "UserUpdate" __version__ = SYFT_OBJECT_VERSION_1 email: EmailStr name: str - role: ServiceRole + role: ServiceRole # make sure role cant be set without uid password: str password_verify: str verify_key: SyftVerifyKey diff --git a/packages/syft/src/syft/service/user/user_roles.py b/packages/syft/src/syft/service/user/user_roles.py index a879d32dcee..d678f4e09e7 100644 --- a/packages/syft/src/syft/service/user/user_roles.py +++ b/packages/syft/src/syft/service/user/user_roles.py @@ -3,6 +3,9 @@ from typing import Any # third party +from pydantic import GetCoreSchemaHandler +from pydantic_core import CoreSchema +from pydantic_core import core_schema from typing_extensions import Self # relative @@ -22,6 +25,12 @@ class ServiceRoleCapability(Enum): CAN_EDIT_DATASITE_SETTINGS = 512 +def _str_to_role(v: Any) -> Any: + if isinstance(v, str) and hasattr(ServiceRole, v.upper()): + return getattr(ServiceRole, v.upper()) + return v + + @serializable(canonical_name="ServiceRole", version=1) class ServiceRole(Enum): NONE = 0 @@ -54,6 +63,19 @@ def roles_for_level(cls, level: int | Self) -> list[Self]: level_float = level_float % role_num return roles + @classmethod + def __get_pydantic_core_schema__( + cls, + _source_type: Any, + _handler: GetCoreSchemaHandler, + ) -> CoreSchema: + return core_schema.chain_schema( + [ + core_schema.no_info_plain_validator_function(_str_to_role), + core_schema.is_instance_schema(cls), + ] + ) + def capabilities(self) -> list[ServiceRoleCapability]: return ROLE_TO_CAPABILITIES[self] From b6ee4ae1a6d5059ed358e39e51f94b4d93a627ac Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 1 Aug 2024 14:21:21 +0800 Subject: [PATCH 13/19] Use top-level imports --- packages/syft/src/syft/client/api.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/syft/src/syft/client/api.py b/packages/syft/src/syft/client/api.py index e7628ccdc3d..3a24a47a617 100644 --- a/packages/syft/src/syft/client/api.py +++ b/packages/syft/src/syft/client/api.py @@ -43,6 +43,8 @@ from ..service.response import SyftSuccess from ..service.service import UserLibConfigRegistry from ..service.service import UserServiceConfigRegistry +from ..service.service import _format_signature +from ..service.service import _signature_error_message from ..service.user.user_roles import ServiceRole from ..service.warnings import APIEndpointWarning from ..service.warnings import WarningContext @@ -1315,10 +1317,6 @@ def validate_callable_args_and_kwargs( else: for key, value in kwargs.items(): if key not in signature.parameters: - # relative - from ..service.service import _format_signature - from ..service.service import _signature_error_message - return SyftError( message=( f"Invalid parameter: `{key}`.\n" @@ -1338,9 +1336,6 @@ def validate_callable_args_and_kwargs( _check_type(value, t) except ValueError: _type_str = getattr(t, "__name__", str(t)) - # relative - from ..service.service import _format_signature - from ..service.service import _signature_error_message return SyftError( message=( @@ -1377,9 +1372,6 @@ def validate_callable_args_and_kwargs( pass else: _type_str = getattr(t, "__name__", str(t)) - # relative - from ..service.service import _format_signature - from ..service.service import _signature_error_message msg = ( f"Arg is `{arg}`. \nIt must be of type `{_type_str}`, not `{type(arg).__name__}`\n" From 847d6ceb8a0c1f2c8fc4fb71bf7f47aa3c54d57c Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 1 Aug 2024 14:35:26 +0800 Subject: [PATCH 14/19] Minor optim --- packages/syft/src/syft/service/user/user_roles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/syft/src/syft/service/user/user_roles.py b/packages/syft/src/syft/service/user/user_roles.py index d678f4e09e7..61cafd4b999 100644 --- a/packages/syft/src/syft/service/user/user_roles.py +++ b/packages/syft/src/syft/service/user/user_roles.py @@ -26,8 +26,8 @@ class ServiceRoleCapability(Enum): def _str_to_role(v: Any) -> Any: - if isinstance(v, str) and hasattr(ServiceRole, v.upper()): - return getattr(ServiceRole, v.upper()) + if isinstance(v, str) and hasattr(ServiceRole, v_upper := v.upper()): + return getattr(ServiceRole, v_upper) return v From c32cdf03cfa2b185ebfad8dea466cd02a73daf4c Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 1 Aug 2024 14:35:56 +0800 Subject: [PATCH 15/19] Add tests for ServiceRole pydantic validator --- packages/syft/tests/syft/users/user_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/syft/tests/syft/users/user_test.py b/packages/syft/tests/syft/users/user_test.py index 00d1743fd71..88a1c230102 100644 --- a/packages/syft/tests/syft/users/user_test.py +++ b/packages/syft/tests/syft/users/user_test.py @@ -3,6 +3,7 @@ # third party from faker import Faker +import pydantic import pytest # syft absolute @@ -435,3 +436,21 @@ def test_user_search( for user in users: assert getattr(user, k) == v + + +class M(pydantic.BaseModel): + role: ServiceRole + + +@pytest.mark.parametrize("role", [x.name for x in ServiceRole]) +class TestServiceRole: + @staticmethod + def test_accept_str_in_base_model(role: str) -> None: + m = M(role=role) + assert m.role is getattr(ServiceRole, role) + + @staticmethod + def test_accept_str(role: str) -> None: + assert pydantic.TypeAdapter(ServiceRole).validate_python(role) is getattr( + ServiceRole, role + ) From 48958c49b23567e6a724b628c14ca7e41d5420d8 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 1 Aug 2024 15:22:38 +0800 Subject: [PATCH 16/19] Refactor test fixtures and utility functions --- tests/integration/local/__init__.py | 0 tests/integration/local/conftest.py | 63 ++++++++++++++++++ .../local/syft_worker_deletion_test.py | 65 ++----------------- 3 files changed, 70 insertions(+), 58 deletions(-) create mode 100644 tests/integration/local/__init__.py create mode 100644 tests/integration/local/conftest.py diff --git a/tests/integration/local/__init__.py b/tests/integration/local/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/local/conftest.py b/tests/integration/local/conftest.py new file mode 100644 index 00000000000..8664244e506 --- /dev/null +++ b/tests/integration/local/conftest.py @@ -0,0 +1,63 @@ +# stdlib +from collections.abc import Generator +from collections.abc import Iterable +from itertools import product +from secrets import token_hex +from typing import Any + +# third party +import pytest + +# syft absolute +import syft as sy +from syft.orchestra import ClientAlias +from syft.orchestra import ServerHandle + + +@pytest.fixture() +def server_args() -> dict[str, Any]: + return {} + + +@pytest.fixture +def server(server_args: dict[str, Any]) -> Generator[ServerHandle, None, None]: + _server = sy.orchestra.launch( + **{ + "name": token_hex(8), + "dev_mode": False, + "reset": True, + "queue_port": None, + "local_db": False, + **server_args, + } + ) + # startup code here + yield _server + # Cleanup code + if (python_server := _server.python_server) is not None: + python_server.cleanup() + _server.land() + + +@pytest.fixture +def client(server: ServerHandle) -> ClientAlias: + return server.login(email="info@openmined.org", password="changethis") + + +def matrix( + *, + excludes_: Iterable[dict[str, Any]] | None = None, + **kwargs: Iterable, +) -> list[dict[str, Any]]: + args = ([(k, v) for v in vs] for k, vs in kwargs.items()) + args = product(*args) + + excludes_ = [] if excludes_ is None else [kv.items() for kv in excludes_] + + args = ( + arg + for arg in args + if not any(all(kv in arg for kv in kvs) for kvs in excludes_) + ) + + return [dict(kvs) for kvs in args] diff --git a/tests/integration/local/syft_worker_deletion_test.py b/tests/integration/local/syft_worker_deletion_test.py index 181030b1a7b..2e2bf714a2c 100644 --- a/tests/integration/local/syft_worker_deletion_test.py +++ b/tests/integration/local/syft_worker_deletion_test.py @@ -1,9 +1,5 @@ # stdlib -from collections.abc import Generator -from collections.abc import Iterable -from itertools import product import operator -from secrets import token_hex import time from typing import Any @@ -13,65 +9,22 @@ # syft absolute import syft as sy -from syft.orchestra import ServerHandle +from syft.orchestra import ClientAlias from syft.service.job.job_stash import JobStatus from syft.service.response import SyftError +# relative +from .conftest import matrix + # equivalent to adding this mark to every test in this file pytestmark = pytest.mark.local_server -@pytest.fixture() -def server_args() -> dict[str, Any]: - return {} - - -@pytest.fixture -def server(server_args: dict[str, Any]) -> Generator[ServerHandle, None, None]: - _server = sy.orchestra.launch( - **{ - "name": token_hex(8), - "dev_mode": True, - "reset": True, - "n_consumers": 3, - "create_producer": True, - "queue_port": None, - "local_db": False, - **server_args, - } - ) - # startup code here - yield _server - # Cleanup code - _server.python_server.cleanup() - _server.land() - - -def matrix( - *, - excludes_: Iterable[dict[str, Any]] | None = None, - **kwargs: Iterable, -) -> list[dict[str, Any]]: - args = ([(k, v) for v in vs] for k, vs in kwargs.items()) - args = product(*args) - - if excludes_ is None: - excludes_ = [] - excludes_ = [kv.items() for kv in excludes_] - - args = ( - arg - for arg in args - if not any(all(kv in arg for kv in kvs) for kvs in excludes_) - ) - - return [dict(kvs) for kvs in args] - - SERVER_ARGS_TEST_CASES = { "n_consumers": [1], "dev_mode": [True, False], "thread_workers": [True, False], + "create_producer": [True], } @@ -90,10 +43,8 @@ class FlakyMark(RuntimeError): ) @pytest.mark.parametrize("force", [True, False]) def test_delete_idle_worker( - server: ServerHandle, force: bool, server_args: dict[str, Any] + client: ClientAlias, force: bool, server_args: dict[str, Any] ) -> None: - client = server.login(email="info@openmined.org", password="changethis") - original_workers = client.worker.get_all() worker_to_delete = max(original_workers, key=operator.attrgetter("name")) @@ -126,9 +77,7 @@ def test_delete_idle_worker( @pytest.mark.parametrize("server_args", matrix(**SERVER_ARGS_TEST_CASES)) @pytest.mark.parametrize("force", [True, False]) -def test_delete_worker(server: ServerHandle, force: bool) -> None: - client = server.login(email="info@openmined.org", password="changethis") - +def test_delete_worker(client: ClientAlias, force: bool) -> None: data = np.array([1, 2, 3]) data_action_obj = sy.ActionObject.from_obj(data) data_pointer = data_action_obj.send(client) From ee24bdbceb02be003e09d4ce7c74711dd83c5574 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 1 Aug 2024 15:25:57 +0800 Subject: [PATCH 17/19] Add an integration test for updating user role with str --- tests/integration/local/user_update_test.py | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/integration/local/user_update_test.py diff --git a/tests/integration/local/user_update_test.py b/tests/integration/local/user_update_test.py new file mode 100644 index 00000000000..3772a05b5ac --- /dev/null +++ b/tests/integration/local/user_update_test.py @@ -0,0 +1,59 @@ +# stdlib +from typing import TypedDict +from uuid import uuid4 + +# third party +import pytest + +# syft absolute +from syft.orchestra import ClientAlias +from syft.service.response import SyftError +from syft.service.user.user_roles import ServiceRole + +# relative +from .conftest import matrix + +pytestmark = pytest.mark.local_server + + +class UserCreateArgs(TypedDict): + name: str + email: str + password: str + password_verify: str + + +@pytest.fixture +def user_create_args() -> UserCreateArgs: + return { + "name": uuid4().hex, + "email": f"{uuid4().hex}@example.org", + "password": (pw := uuid4().hex), + "password_verify": pw, + } + + +@pytest.fixture +def user(client: ClientAlias, user_create_args: UserCreateArgs) -> ClientAlias: + res = client.register(**user_create_args) + assert not isinstance(res, SyftError) + + return client.login( + email=user_create_args["email"], + password=user_create_args["password"], + ) + + +@pytest.mark.parametrize("server_args", matrix(port=[None, "auto"])) +def test_user_update_role_str(client: ClientAlias, user: ClientAlias) -> None: + res = client.users.update(uid=user.account.id, role="admin") + assert not isinstance(res, SyftError) + + user.refresh() + assert user.account.role is ServiceRole.ADMIN + + res = user.account.update(role="data_scientist") + assert not isinstance(res, SyftError) + + user.refresh() + assert user.account.role is ServiceRole.DATA_SCIENTIST From 91a53800ab179ebcc95044eff8a362d45b6efb15 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 1 Aug 2024 16:17:41 +0800 Subject: [PATCH 18/19] Add a unit test for updating server settings --- packages/syft/src/syft/service/service.py | 10 ++++--- .../syft/settings/settings_service_test.py | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/syft/src/syft/service/service.py b/packages/syft/src/syft/service/service.py index 6a609b2bd02..e313edc3714 100644 --- a/packages/syft/src/syft/service/service.py +++ b/packages/syft/src/syft/service/service.py @@ -309,11 +309,13 @@ def _format_signature(s: inspect.Signature) -> inspect.Signature: ) +_SIGNATURE_ERROR_MESSAGE = ( + "Please provide the correct arguments to the method according to this signature" +) + + def _signature_error_message(s: inspect.Signature) -> str: - return ( - f"Please provide the correct arguments to the method " - f"according to this signature\n{s}." - ) + return f"{_SIGNATURE_ERROR_MESSAGE}\n{s}" def reconstruct_args_kwargs( diff --git a/packages/syft/tests/syft/settings/settings_service_test.py b/packages/syft/tests/syft/settings/settings_service_test.py index 78b6ab7d9b1..03af399bc8f 100644 --- a/packages/syft/tests/syft/settings/settings_service_test.py +++ b/packages/syft/tests/syft/settings/settings_service_test.py @@ -13,6 +13,7 @@ # syft absolute import syft from syft.abstract_server import ServerSideType +from syft.client.datasite_client import DatasiteClient from syft.server.credentials import SyftSigningKey from syft.server.credentials import SyftVerifyKey from syft.service.context import AuthedServiceContext @@ -20,6 +21,7 @@ from syft.service.notifier.notifier_stash import NotifierStash from syft.service.response import SyftError from syft.service.response import SyftSuccess +from syft.service.service import _SIGNATURE_ERROR_MESSAGE from syft.service.settings.settings import ServerSettings from syft.service.settings.settings import ServerSettingsUpdate from syft.service.settings.settings_service import SettingsService @@ -409,3 +411,27 @@ def get_mock_client(faker, root_client, role): [u.email in emails_added for u in root_client.users.get_all()] ) assert users_created_count == len(emails_added) + + +def test_invalid_args_error_message(root_datasite_client: DatasiteClient) -> None: + update_args = { + "name": uuid4().hex, + "organization": uuid4().hex, + } + + update = ServerSettingsUpdate(**update_args) + + res = root_datasite_client.api.services.settings.update(settings=update) + assert isinstance(res, SyftError) + assert _SIGNATURE_ERROR_MESSAGE in res.message + + res = root_datasite_client.api.services.settings.update(update) + assert isinstance(res, SyftError) + assert _SIGNATURE_ERROR_MESSAGE in res.message + + res = root_datasite_client.api.services.settings.update(**update_args) + assert not isinstance(res, SyftError) + + settings = root_datasite_client.api.services.settings.get() + assert settings.name == update_args["name"] + assert settings.organization == update_args["organization"] From dcf64e838b8c8e6c8b46c3e83ef8a84babb23493 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Mon, 12 Aug 2024 13:18:38 +0800 Subject: [PATCH 19/19] Lint --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 816caf5eae4..adc37f38d57 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ certifi>=2024.7.4 # not directly required, pinned by Snyk to avoid a vulnerability +idna>=3.7 # not directly required, pinned by Snyk to avoid a vulnerability ipython==8.10.0 jinja2>=3.1.4 # not directly required, pinned by Snyk to avoid a vulnerability markupsafe==2.0.1 @@ -12,4 +13,3 @@ sphinx-code-include==1.1.1 sphinx-copybutton==0.4.0 sphinx-panels==0.6.0 urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability -idna>=3.7 # not directly required, pinned by Snyk to avoid a vulnerability