apheris-utils 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apheris_utils/__init__.py +9 -0
- apheris_utils/data/__init__.py +13 -0
- apheris_utils/data/_dal_client.py +85 -0
- apheris_utils/data/primitives/__init__.py +17 -0
- apheris_utils/data/primitives/_primitives.py +152 -0
- apheris_utils/data/primitives/_settings.py +45 -0
- apheris_utils/extras_nvflare/__init__.py +13 -0
- apheris_utils/extras_nvflare/logging/__init__.py +11 -0
- apheris_utils/extras_nvflare/logging/gateway_log_sender.py +82 -0
- apheris_utils/extras_nvflare/logging/orchestrator_log_receiver.py +30 -0
- apheris_utils/extras_nvflare/logging/util.py +63 -0
- apheris_utils-0.6.0.dist-info/LICENSE +21 -0
- apheris_utils-0.6.0.dist-info/METADATA +13 -0
- apheris_utils-0.6.0.dist-info/RECORD +16 -0
- apheris_utils-0.6.0.dist-info/WHEEL +5 -0
- apheris_utils-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Dict, List, Union
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from apheris_utils.data.primitives import get, list_remote_files, settings, to_folder
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_asset_policies(
|
|
10
|
+
dataset_ids: Union[str, List[str]],
|
|
11
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
12
|
+
"""
|
|
13
|
+
Retrieves policy details for one or more datasets from the configured policy endpoint.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
dataset_ids (Union[str, List[str]]): A single dataset id or a list of dataset ids for which policies are requested.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
RuntimeError: If the request to the policy endpoint fails.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Union[Dict[str, Any], Dict[str, Dict[str, Any]]]: A dictionary containing the policy details for the requested dataset(s).
|
|
23
|
+
If called with a single dataset id, returns a dictionary with the policy details. If called with a list of dataset ids,
|
|
24
|
+
returns a dictionary of dictionaries with each dataset's policy details.
|
|
25
|
+
"""
|
|
26
|
+
if isinstance(dataset_ids, str):
|
|
27
|
+
r = requests.get(
|
|
28
|
+
f"{settings.policy_endpoint}{settings.data[dataset_ids]}",
|
|
29
|
+
headers=settings.headers,
|
|
30
|
+
)
|
|
31
|
+
if r.status_code != 200:
|
|
32
|
+
raise RuntimeError(f"Failed to get policy: {r.text}")
|
|
33
|
+
|
|
34
|
+
return {dataset_ids: r.json()}
|
|
35
|
+
|
|
36
|
+
policies = {}
|
|
37
|
+
for d in dataset_ids:
|
|
38
|
+
policies.update(get_asset_policies(d))
|
|
39
|
+
|
|
40
|
+
return policies
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Alias to load_dataset
|
|
44
|
+
def download_dataset(dataset_id: str, folder: Union[str, Path]) -> Dict[str, Path]:
|
|
45
|
+
"""
|
|
46
|
+
Load a dataset from the DAL and save it to a folder.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
dataset_id (str): The ID of the dataset to load.
|
|
50
|
+
folder (Union[str, Path]): The folder where the dataset will be saved.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Dict[str,str]: A dictionary mapping dataset id to their saved locations
|
|
54
|
+
"""
|
|
55
|
+
return get(list_remote_files(dataset_id), to_folder(folder))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_dataset_ids() -> List[str]:
|
|
59
|
+
"""
|
|
60
|
+
List all available dataset ids.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List[str]: A list of all available dataset ids.
|
|
64
|
+
"""
|
|
65
|
+
return list(settings.data.keys())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Alias to download all datasets
|
|
69
|
+
def download_all(folder: Union[str, Path]) -> Dict[str, Path]:
|
|
70
|
+
"""
|
|
71
|
+
Downloads all datasets specified in the settings and saves them to the provided folder.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
folder (Union[str, Path]): The path to the directory where the datasets will be stored.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Dict[str, Union[str, Dict[str,str]]]: A dictionary mapping dataset IDs to their saved locations
|
|
78
|
+
"""
|
|
79
|
+
# We get a list of dataset ids such as `my-data`, `my-slug`
|
|
80
|
+
dataset_ids = list_dataset_ids()
|
|
81
|
+
output = {}
|
|
82
|
+
for dataset_id in dataset_ids:
|
|
83
|
+
remote_files = list_remote_files(dataset_id)
|
|
84
|
+
output.update(get(remote_files, to_folder(folder)))
|
|
85
|
+
return output
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
2
|
+
from functools import partial
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from apheris_utils.data.primitives._settings import settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RemoteFile(BaseModel):
|
|
13
|
+
"""
|
|
14
|
+
RemoteFile encapsulates the details about the data including its internal key and the path used for local processing.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
_key (str): The internal key to access the data, typically a URL like s3://data/object.csv.
|
|
18
|
+
_parent (str): The parent key if the file is part of a dataset with a folder key. Example: parent = s3://data/
|
|
19
|
+
path (str): The path derived from the _key for local processing or storage. This is calculated based on prefix provided during initialization.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
id: str
|
|
23
|
+
key: str = Field(..., alias="_key")
|
|
24
|
+
parent: Optional[str] = Field(None, alias="_parent")
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def path(self) -> str:
|
|
28
|
+
core_path: str
|
|
29
|
+
if self.parent:
|
|
30
|
+
# With key s3://folder/data/data.csv and parent s3://folder/ the core path is data/data.csv
|
|
31
|
+
core_path = self.key[len(self.parent) :]
|
|
32
|
+
else: # with key file:///data.csv the core path is data.csv
|
|
33
|
+
core_path = self.key.split("/")[-1:][0]
|
|
34
|
+
|
|
35
|
+
return f"{self.id}/{core_path}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_remote_files(dataset_id: str) -> List[RemoteFile]:
|
|
39
|
+
"""
|
|
40
|
+
Retrieves a list of remote files corresponding to all files contained within a specified dataset identified by its unique dataset id.
|
|
41
|
+
The unique dataset can be seen by listing datasets
|
|
42
|
+
A dataset ID is a unique identifier that can be used to access specific datasets in a data access layer (DAL).
|
|
43
|
+
Returns:
|
|
44
|
+
List[str]: A list of keys representing remote files that are accessible through the DAL.
|
|
45
|
+
"""
|
|
46
|
+
key = settings.data[dataset_id]
|
|
47
|
+
|
|
48
|
+
if key.endswith("/"):
|
|
49
|
+
r = requests.get(f"{settings.dataset_endpoint}{key}", headers=settings.headers)
|
|
50
|
+
|
|
51
|
+
if r.status_code != 200:
|
|
52
|
+
raise RuntimeError(f"Failed to list datasets: {r.text}")
|
|
53
|
+
|
|
54
|
+
keys = r.json()
|
|
55
|
+
while link := r.headers.get("Link"):
|
|
56
|
+
r = requests.get(
|
|
57
|
+
f"{settings.dataset_endpoint}{link}", headers=settings.headers
|
|
58
|
+
)
|
|
59
|
+
keys += r.json()
|
|
60
|
+
|
|
61
|
+
files = [RemoteFile(id=dataset_id, _key=k, _parent=key) for k in keys]
|
|
62
|
+
else:
|
|
63
|
+
files = [RemoteFile(id=dataset_id, _key=key, _parent=None)]
|
|
64
|
+
|
|
65
|
+
return files
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get(
|
|
69
|
+
remote_files: Union[RemoteFile, List[RemoteFile]],
|
|
70
|
+
output_func: Callable[[RemoteFile, bytes], Dict[str, Any]],
|
|
71
|
+
) -> Dict[str, Any]:
|
|
72
|
+
"""
|
|
73
|
+
Fetches the data specified by `remote_files` and applies a provided output function to handle the retrieved content.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
remote_files (Union[RemoteFile, List[RemoteFile]]): A single RemoteFile or a list of RemoteFiles to retrieve.
|
|
77
|
+
output_func (Callable[[RemoteFile, bytes], Dict[str,Any]]): A function that processes the retrieved data. It accepts
|
|
78
|
+
the RemoteFile object and its content in bytes.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Dict[str, Any]: A dictionary mapping keys to the processed results from the output function.
|
|
82
|
+
For single files, returns the direct output dictionary.
|
|
83
|
+
For multiple files, returns a merged dictionary of all results.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(remote_files, RemoteFile):
|
|
86
|
+
r = requests.get(
|
|
87
|
+
f"{settings.dataset_endpoint}{remote_files.key}", headers=settings.headers
|
|
88
|
+
)
|
|
89
|
+
if r.status_code != 200:
|
|
90
|
+
raise RuntimeError(f"Failed to get key: {r.text}")
|
|
91
|
+
return output_func(remote_files, r.content)
|
|
92
|
+
|
|
93
|
+
with ThreadPoolExecutor(max_workers=settings.max_workers) as executor:
|
|
94
|
+
results = executor.map(partial(get, output_func=output_func), remote_files)
|
|
95
|
+
return {k: v for result in results for k, v in result.items()}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def to_folder(folder: Union[str, Path]) -> Callable[[RemoteFile, bytes], Dict[str, Path]]:
|
|
99
|
+
"""
|
|
100
|
+
Create a callable function for saving content to a specified folder.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
folder (Union[str, Path]): The root directory where files will be saved.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Callable[[RemoteFile, bytes], Dict[str, Any]]: A function that takes a RemoteFile and its content as bytes,
|
|
107
|
+
then saves it to the destination derived from the RemoteFile's path
|
|
108
|
+
and returns a dict mapping the RemoteFile's ID to the saved path.
|
|
109
|
+
"""
|
|
110
|
+
if isinstance(folder, str):
|
|
111
|
+
folder = Path(folder)
|
|
112
|
+
# Make sure folder exists
|
|
113
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
def output_func(remote_file: RemoteFile, content: bytes) -> Dict[str, Path]:
|
|
116
|
+
# Parse key to extract the complete path excluding the protocol
|
|
117
|
+
# Example paths:
|
|
118
|
+
# s3://data/folder1/folder2/data.csv -> folder1/folder2/data.csv
|
|
119
|
+
# file:///container/folder1/folder2 -> container/folder1/folder2
|
|
120
|
+
full_path = folder / remote_file.path
|
|
121
|
+
|
|
122
|
+
if full_path != full_path.resolve():
|
|
123
|
+
raise ValueError("Access denied: Path traversal attempt detected")
|
|
124
|
+
|
|
125
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
|
|
127
|
+
with open(full_path, "wb") as f:
|
|
128
|
+
f.write(content)
|
|
129
|
+
|
|
130
|
+
# If it's a container dataset, return the destination folder e.g. destination/slug/
|
|
131
|
+
if remote_file.parent:
|
|
132
|
+
return {remote_file.id: folder / remote_file.id}
|
|
133
|
+
|
|
134
|
+
# If not return the full path e.g. destination/slug/data.csv
|
|
135
|
+
return {remote_file.id: full_path}
|
|
136
|
+
|
|
137
|
+
return output_func
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def to_dict() -> Callable[[RemoteFile, bytes], Dict[str, Any]]:
|
|
141
|
+
"""
|
|
142
|
+
Create a callable that stores content bytes in a dictionary under RemoteFile's ID.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Callable[[RemoteFile, bytes], Dict[str, Any]]: A function that takes a RemoteFile and content bytes,
|
|
146
|
+
returning a dictionary mapping the RemoteFile's ID to its content.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def output_func(remote_file: RemoteFile, content: bytes):
|
|
150
|
+
return {remote_file.path: content}
|
|
151
|
+
|
|
152
|
+
return output_func
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import AnyHttpUrl, Field
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_env_file() -> Union[Path, str, None]:
|
|
10
|
+
"""Supports multiple environment configurations based on the
|
|
11
|
+
APH_ENV environment variable.
|
|
12
|
+
|
|
13
|
+
The default environment will be always local.
|
|
14
|
+
"""
|
|
15
|
+
# Make sure we don't load a spurious .env file by mistake
|
|
16
|
+
env_name = os.getenv("APH_ENV", "local")
|
|
17
|
+
env_file_prefix = ".env"
|
|
18
|
+
env_file_path = f"{env_file_prefix}-{env_name.lower()}"
|
|
19
|
+
|
|
20
|
+
return env_file_path if os.path.exists(env_file_path) else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DALSettings(BaseSettings):
|
|
24
|
+
api_dal_url: AnyHttpUrl = AnyHttpUrl("http://test-api")
|
|
25
|
+
dal_token: str = "test-token"
|
|
26
|
+
data: Dict[str, str] = Field(default_factory=lambda: {"test": "test-dataset"})
|
|
27
|
+
max_workers: int = 50
|
|
28
|
+
|
|
29
|
+
model_config = SettingsConfigDict(env_prefix="APH_", env_file_encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def headers(self) -> Dict[str, str]:
|
|
33
|
+
return {"Authorization": f"Bearer {self.dal_token}"}
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def dataset_endpoint(self) -> str:
|
|
37
|
+
# HttpUrl type adds a trailing slash
|
|
38
|
+
return f"{self.api_dal_url}datasets/"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def policy_endpoint(self) -> str:
|
|
42
|
+
return f"{self.api_dal_url}policies/"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
settings = DALSettings(_env_file=get_env_file())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from . import logging
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"logging",
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise ImportError(
|
|
10
|
+
"NVFlare package not found. Please install "
|
|
11
|
+
"apheris-utils with the 'nvflare' extra to use this module. "
|
|
12
|
+
"`pip install apheris-utils[nvflare]`"
|
|
13
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from nvflare.apis.dxo import DXO, DataKind
|
|
5
|
+
from nvflare.apis.fl_component import FLComponent
|
|
6
|
+
from nvflare.apis.fl_constant import FLContextKey, ReturnCode
|
|
7
|
+
from nvflare.apis.fl_context import FLContext
|
|
8
|
+
from nvflare.apis.shareable import make_reply
|
|
9
|
+
|
|
10
|
+
from .util import get_logger_fn_for_level, sanitised_trace
|
|
11
|
+
|
|
12
|
+
MESSAGE_TO_SERVER_DELAY = 0.01
|
|
13
|
+
|
|
14
|
+
message_to_server_delay = os.environ.get(
|
|
15
|
+
"APH_MESSAGE_TO_SERVER_DELAY", MESSAGE_TO_SERVER_DELAY
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GatewayLogSenderMixin(FLComponent):
|
|
20
|
+
|
|
21
|
+
def _send_to_server_log(self, fl_ctx: FLContext, message: str, level="INFO") -> None:
|
|
22
|
+
"""
|
|
23
|
+
Sends message to NVFlare server to log information there.
|
|
24
|
+
"""
|
|
25
|
+
self.log_info(fl_ctx, "Sending message to server log")
|
|
26
|
+
get_logger_fn_for_level(component=self, level=level)(fl_ctx, message)
|
|
27
|
+
dxo = DXO(
|
|
28
|
+
DataKind.COLLECTION,
|
|
29
|
+
{
|
|
30
|
+
"message": message,
|
|
31
|
+
"level": level,
|
|
32
|
+
"client_name": fl_ctx.get_prop(FLContextKey.CLIENT_NAME),
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
msg_data = dxo.to_shareable()
|
|
36
|
+
self.fire_fed_event(
|
|
37
|
+
event_type="debug_message", event_data=msg_data, fl_ctx=fl_ctx
|
|
38
|
+
)
|
|
39
|
+
time.sleep(float(message_to_server_delay))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def safe_error_catchall_decorator(func):
|
|
43
|
+
"""
|
|
44
|
+
A decorator to wrap functions that might throw an unhandled exception, catch the
|
|
45
|
+
exception then sanitise it by removing the raw error message and just passing limited
|
|
46
|
+
details back to the user.
|
|
47
|
+
|
|
48
|
+
Will try to use an FLComponents log_xxx methods, but if unavailable, will simply
|
|
49
|
+
print to the terminal.
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def wrapper(self, *args, **kwargs):
|
|
54
|
+
send_to_server = isinstance(self, GatewayLogSenderMixin)
|
|
55
|
+
|
|
56
|
+
fl_ctx = None
|
|
57
|
+
for a in args:
|
|
58
|
+
if isinstance(a, FLContext):
|
|
59
|
+
fl_ctx = a
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
if fl_ctx is None:
|
|
63
|
+
print(
|
|
64
|
+
"Could not find an FLContext in this function so can not forward"
|
|
65
|
+
" errors to server"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
return func(self, *args, **kwargs)
|
|
70
|
+
except Exception as err:
|
|
71
|
+
if fl_ctx and send_to_server:
|
|
72
|
+
self._send_to_server_log(
|
|
73
|
+
fl_ctx,
|
|
74
|
+
sanitised_trace(),
|
|
75
|
+
"ERROR",
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
# This will only print on the client, won't be passed back
|
|
79
|
+
print(str(err))
|
|
80
|
+
return make_reply(ReturnCode.ERROR)
|
|
81
|
+
|
|
82
|
+
return wrapper
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from nvflare.apis.dxo import from_shareable
|
|
2
|
+
from nvflare.apis.fl_component import FLComponent
|
|
3
|
+
from nvflare.apis.fl_constant import FLContextKey
|
|
4
|
+
from nvflare.apis.fl_context import FLContext
|
|
5
|
+
|
|
6
|
+
from .util import get_logger_fn_for_level
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrchestratorLogReceiver(FLComponent):
|
|
10
|
+
"""
|
|
11
|
+
A component that receives messages sent to the Orchestrator from the Gateways as
|
|
12
|
+
FLARE events and writes them into the Orchestrator logs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def handle_event(self, event_type: str, fl_ctx: FLContext):
|
|
16
|
+
super().handle_event(event_type=event_type, fl_ctx=fl_ctx)
|
|
17
|
+
|
|
18
|
+
if event_type == "debug_message":
|
|
19
|
+
shareable = fl_ctx.get_prop(key=FLContextKey.EVENT_DATA)
|
|
20
|
+
dxo = from_shareable(shareable)
|
|
21
|
+
data = dxo.data
|
|
22
|
+
client_message = data["message"]
|
|
23
|
+
client_name = data["client_name"]
|
|
24
|
+
level = data["level"]
|
|
25
|
+
|
|
26
|
+
get_logger_fn_for_level(component=self, level=level)(
|
|
27
|
+
fl_ctx,
|
|
28
|
+
f"OrchestratorLogReceiver: Message from '{client_name} [{level}]': "
|
|
29
|
+
f"{client_message}",
|
|
30
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import traceback
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable, Literal, Optional
|
|
5
|
+
|
|
6
|
+
from nvflare.apis.fl_component import FLComponent
|
|
7
|
+
|
|
8
|
+
LogLevel = Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_logger_fn_for_level(component: FLComponent, level: LogLevel) -> Callable:
|
|
12
|
+
if level == "CRITICAL":
|
|
13
|
+
return component.log_critical
|
|
14
|
+
elif level == "ERROR":
|
|
15
|
+
return component.log_error
|
|
16
|
+
elif level == "WARNING":
|
|
17
|
+
return component.log_warning
|
|
18
|
+
elif level == "DEBUG":
|
|
19
|
+
return component.log_debug
|
|
20
|
+
else:
|
|
21
|
+
return component.log_info
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ExceptionData:
|
|
26
|
+
type: str
|
|
27
|
+
line: Optional[str] = None
|
|
28
|
+
function_name: Optional[str] = None
|
|
29
|
+
filename: Optional[str] = None
|
|
30
|
+
line_number: Optional[int] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract_exception_data() -> ExceptionData:
|
|
34
|
+
"""
|
|
35
|
+
Call from inside an active exception to extract key information but hide the full
|
|
36
|
+
stacktrace. Can be used to create sanitised error messages to send from the client
|
|
37
|
+
to the server.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# We explicitly don't extract the exc_value as this can include sensitive
|
|
41
|
+
# information
|
|
42
|
+
exc_type, _, exc_traceback = sys.exc_info()
|
|
43
|
+
tb = traceback.extract_tb(exc_traceback)
|
|
44
|
+
|
|
45
|
+
return ExceptionData(
|
|
46
|
+
line=tb[-1].line,
|
|
47
|
+
function_name=tb[-1].name,
|
|
48
|
+
filename=tb[-1].filename,
|
|
49
|
+
line_number=tb[-1].lineno,
|
|
50
|
+
type=exc_type.__name__ if exc_type else "Unknown",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_exception_data(d: ExceptionData) -> str:
|
|
55
|
+
return (
|
|
56
|
+
f"Exception of type '{d.type}' has occurred in"
|
|
57
|
+
f" {d.filename}:{d.line_number} (function: '{d.function_name}').\n"
|
|
58
|
+
f"The exception was caused by the line '{d.line}'"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sanitised_trace() -> str:
|
|
63
|
+
return format_exception_data(extract_exception_data())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Apheris GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: apheris-utils
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Utility functions for Apheris projects
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Dist: pydantic==2.10.6
|
|
7
|
+
Requires-Dist: pydantic-settings==2.8.1
|
|
8
|
+
Requires-Dist: requests==2.32.3
|
|
9
|
+
Provides-Extra: nvflare
|
|
10
|
+
Requires-Dist: nvflare<2.6,>=2.4; extra == "nvflare"
|
|
11
|
+
Dynamic: provides-extra
|
|
12
|
+
Dynamic: requires-dist
|
|
13
|
+
Dynamic: summary
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
apheris_utils/__init__.py,sha256=lT7baPkuny_CeifqXq_e54j4md7DN12v_S6fhoxpyz4,158
|
|
2
|
+
apheris_utils/data/__init__.py,sha256=el_dQJgrcxpuGM7HmJjzi4pKSZ0RlN4Osw2fUeDX2FQ,242
|
|
3
|
+
apheris_utils/data/_dal_client.py,sha256=Ara2ZT24Jk3P3CeuDjfFw3A_3yJ-HOSUvUunOnL_JH4,2781
|
|
4
|
+
apheris_utils/data/primitives/__init__.py,sha256=XpbdwSRxDKm_zho8YuZ2XO1RTOUEX03zmAUPwncvLXY,236
|
|
5
|
+
apheris_utils/data/primitives/_primitives.py,sha256=GxsDRNL5yAhfGopy20CLFZLAAjG8Z2D7FSS7BmBfYTI,6222
|
|
6
|
+
apheris_utils/data/primitives/_settings.py,sha256=j8tRMjB6oc5StPmSWfBD5e2FXz9wf1LgGhtJkx_Puv4,1375
|
|
7
|
+
apheris_utils/extras_nvflare/__init__.py,sha256=tQK4BdNxBEcsXi7DIKM0FH5bEWCPMpxWnD9DULuYcgE,293
|
|
8
|
+
apheris_utils/extras_nvflare/logging/__init__.py,sha256=QfgtiRDKEJ044Xj6J7jFJbjdccVBWbOYLcQxfHrZDVI,169
|
|
9
|
+
apheris_utils/extras_nvflare/logging/gateway_log_sender.py,sha256=pURFw3gF3griHpvs_Rv5XCVQv0cECA2JDVIIPFFkQzE,2552
|
|
10
|
+
apheris_utils/extras_nvflare/logging/orchestrator_log_receiver.py,sha256=xygpE9su_IGh2xn-NisHNBm8vIYy4g5G_amlWeQ_0pg,1109
|
|
11
|
+
apheris_utils/extras_nvflare/logging/util.py,sha256=qkk0v6v-sm5CViK8XQVVIw9jZq3kYPO-1joGKHdH16U,1829
|
|
12
|
+
apheris_utils-0.6.0.dist-info/LICENSE,sha256=Y0GNoZQK3WCzQkhYvD1Wq1uwdCb1rifIQWZjc8gmAaI,1069
|
|
13
|
+
apheris_utils-0.6.0.dist-info/METADATA,sha256=iHjPa-4XH4ZygSdxbKk7ok1Y38WM_n2DZu5PCS83Tmc,372
|
|
14
|
+
apheris_utils-0.6.0.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
|
|
15
|
+
apheris_utils-0.6.0.dist-info/top_level.txt,sha256=86JUzsnJ8zzFZ50NgbpLCuJm9niysx5nUVawZe2F0SI,14
|
|
16
|
+
apheris_utils-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apheris_utils
|