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.
@@ -0,0 +1,9 @@
1
+ from . import data
2
+
3
+ try:
4
+ from . import extras_nvflare # noqa: F401
5
+
6
+ __all__ = ["data", "extras_nvflare"]
7
+
8
+ except ImportError:
9
+ __all__ = ["data"]
@@ -0,0 +1,13 @@
1
+ from apheris_utils.data._dal_client import (
2
+ download_all,
3
+ download_dataset,
4
+ get_asset_policies,
5
+ list_dataset_ids,
6
+ )
7
+
8
+ __all__ = [
9
+ "download_all",
10
+ "download_dataset",
11
+ "get_asset_policies",
12
+ "list_dataset_ids",
13
+ ]
@@ -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,17 @@
1
+ from ._primitives import (
2
+ RemoteFile,
3
+ get,
4
+ list_remote_files,
5
+ settings,
6
+ to_dict,
7
+ to_folder,
8
+ )
9
+
10
+ __all__ = [
11
+ "get",
12
+ "list_remote_files",
13
+ "to_folder",
14
+ "to_dict",
15
+ "settings",
16
+ "RemoteFile",
17
+ ]
@@ -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,11 @@
1
+ from . import (
2
+ gateway_log_sender,
3
+ orchestrator_log_receiver,
4
+ util,
5
+ )
6
+
7
+ __all__ = [
8
+ "gateway_log_sender",
9
+ "orchestrator_log_receiver",
10
+ "util",
11
+ ]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (76.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ apheris_utils