appmesh 1.3.5__py3-none-any.whl → 1.3.7__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.
- appmesh/__init__.py +18 -1
- appmesh/app.py +226 -0
- appmesh/app_output.py +26 -0
- appmesh/app_run.py +48 -0
- appmesh/appmesh_client.py +167 -700
- appmesh/appmesh_client_tcp.py +362 -0
- {appmesh-1.3.5.dist-info → appmesh-1.3.7.dist-info}/METADATA +1 -1
- appmesh-1.3.7.dist-info/RECORD +10 -0
- appmesh-1.3.5.dist-info/RECORD +0 -6
- {appmesh-1.3.5.dist-info → appmesh-1.3.7.dist-info}/WHEEL +0 -0
- {appmesh-1.3.5.dist-info → appmesh-1.3.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,362 @@
|
|
1
|
+
"""Application output information"""
|
2
|
+
|
3
|
+
# pylint: disable=line-too-long,broad-exception-raised, ,broad-exception-caught,import-outside-toplevel,protected-access
|
4
|
+
|
5
|
+
# Standard library imports
|
6
|
+
import json
|
7
|
+
import os
|
8
|
+
import socket
|
9
|
+
import ssl
|
10
|
+
import uuid
|
11
|
+
|
12
|
+
# Third-party imports
|
13
|
+
import requests
|
14
|
+
import msgpack
|
15
|
+
|
16
|
+
# Local application-specific imports
|
17
|
+
from .appmesh_client import AppMeshClient
|
18
|
+
|
19
|
+
|
20
|
+
class AppMeshClientTCP(AppMeshClient):
|
21
|
+
"""
|
22
|
+
Client SDK for interacting with the App Mesh service over TCP, with enhanced support for large file transfers.
|
23
|
+
|
24
|
+
The `AppMeshClientTCP` class extends the functionality of `AppMeshClient` by offering a TCP-based communication layer
|
25
|
+
for the App Mesh REST API. It overrides the file download and upload methods to support large file transfers with
|
26
|
+
improved performance, leveraging TCP for lower latency and higher throughput compared to HTTP.
|
27
|
+
|
28
|
+
This client is suitable for applications requiring efficient data transfers and high-throughput operations within the
|
29
|
+
App Mesh ecosystem, while maintaining compatibility with all other attributes and methods from `AppMeshClient`.
|
30
|
+
|
31
|
+
Dependency:
|
32
|
+
- Install the required package for message serialization:
|
33
|
+
pip3 install msgpack
|
34
|
+
|
35
|
+
Usage:
|
36
|
+
- Import the client module:
|
37
|
+
from appmesh import appmesh_client
|
38
|
+
|
39
|
+
Example:
|
40
|
+
client = appmesh_client.AppMeshClientTCP()
|
41
|
+
client.login("your-name", "your-password")
|
42
|
+
client.file_download("/tmp/os-release", "os-release")
|
43
|
+
|
44
|
+
Attributes:
|
45
|
+
- Inherits all attributes from `AppMeshClient`, including TLS secure connections and JWT-based authentication.
|
46
|
+
- Optimized for TCP-based communication to provide better performance for large file transfers.
|
47
|
+
|
48
|
+
Methods:
|
49
|
+
- file_download()
|
50
|
+
- file_upload()
|
51
|
+
- Inherits all other methods from `AppMeshClient`, providing a consistent interface for managing applications within App Mesh.
|
52
|
+
"""
|
53
|
+
|
54
|
+
TCP_BLOCK_SIZE = 16 * 1024 - 128 # TLS-optimized chunk size, leaves some room for TLS overhead (like headers) within the 16 KB limit.
|
55
|
+
TCP_HEADER_LENGTH = 4
|
56
|
+
ENCODING_UTF8 = "utf-8"
|
57
|
+
HTTP_USER_AGENT_TCP = "appmesh/python/tcp"
|
58
|
+
HTTP_HEADER_KEY_X_SEND_FILE_SOCKET = "X-Send-File-Socket"
|
59
|
+
HTTP_HEADER_KEY_X_RECV_FILE_SOCKET = "X-Recv-File-Socket"
|
60
|
+
|
61
|
+
def __init__(
|
62
|
+
self,
|
63
|
+
rest_ssl_verify=AppMeshClient.DEFAULT_SSL_CA_CERT_PATH if os.path.exists(AppMeshClient.DEFAULT_SSL_CA_CERT_PATH) else False,
|
64
|
+
rest_ssl_client_cert=None,
|
65
|
+
jwt_token=None,
|
66
|
+
tcp_address=("localhost", 6059),
|
67
|
+
):
|
68
|
+
"""Construct an App Mesh client TCP object to communicate securely with an App Mesh server over TLS.
|
69
|
+
|
70
|
+
Args:
|
71
|
+
rest_ssl_verify (Union[bool, str], optional): Specifies SSL certificate verification behavior. Can be:
|
72
|
+
- `True`: Uses the system’s default CA certificates to verify the server’s identity.
|
73
|
+
- `False`: Disables SSL certificate verification (insecure, intended for development).
|
74
|
+
- `str`: Specifies a custom CA bundle or directory for server certificate verification. If a string is provided,
|
75
|
+
it should either be a file path to a custom CA certificate (CA bundle) or a directory path containing multiple
|
76
|
+
certificates (CA directory).
|
77
|
+
|
78
|
+
**Note**: Unlike HTTP requests, TCP connections cannot automatically retrieve intermediate or public CA certificates.
|
79
|
+
When `rest_ssl_verify` is a path, it explicitly identifies a CA issuer to ensure certificate validation.
|
80
|
+
|
81
|
+
rest_ssl_client_cert (Union[str, Tuple[str, str]], optional): Path to the SSL client certificate and key. If a `str`,
|
82
|
+
it should be the path to a PEM file containing both the client certificate and private key. If a `tuple`, it should
|
83
|
+
be a pair of paths: (`cert`, `key`), where `cert` is the client certificate file and `key` is the private key file.
|
84
|
+
|
85
|
+
jwt_token (str, optional): JWT token for authentication. Used in methods requiring login and user authorization.
|
86
|
+
|
87
|
+
tcp_address (Tuple[str, int], optional): Address and port for establishing a TCP connection to the server.
|
88
|
+
Defaults to `("localhost", 6059)`.
|
89
|
+
"""
|
90
|
+
self.tcp_address = tcp_address
|
91
|
+
self.__socket_client = None
|
92
|
+
super().__init__(rest_ssl_verify=rest_ssl_verify, rest_ssl_client_cert=rest_ssl_client_cert, jwt_token=jwt_token)
|
93
|
+
|
94
|
+
def __del__(self) -> None:
|
95
|
+
"""De-construction"""
|
96
|
+
self.__close_socket()
|
97
|
+
|
98
|
+
def __connect_socket(self) -> ssl.SSLSocket:
|
99
|
+
"""Establish tcp connection"""
|
100
|
+
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
101
|
+
# Set minimum TLS version
|
102
|
+
if hasattr(context, "minimum_version"):
|
103
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
104
|
+
else:
|
105
|
+
context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
|
106
|
+
# Configure SSL verification
|
107
|
+
if not self.ssl_verify:
|
108
|
+
context.verify_mode = ssl.CERT_NONE
|
109
|
+
else:
|
110
|
+
context.verify_mode = ssl.CERT_REQUIRED # Require certificate verification
|
111
|
+
context.load_default_certs() # Load system's default CA certificates
|
112
|
+
if isinstance(self.ssl_verify, str):
|
113
|
+
if os.path.isfile(self.ssl_verify):
|
114
|
+
# Load custom CA certificate file
|
115
|
+
context.load_verify_locations(cafile=self.ssl_verify)
|
116
|
+
elif os.path.isdir(self.ssl_verify):
|
117
|
+
# Load CA certificates from directory
|
118
|
+
context.load_verify_locations(capath=self.ssl_verify)
|
119
|
+
else:
|
120
|
+
raise ValueError(f"ssl_verify path '{self.ssl_verify}' is neither a file nor a directory")
|
121
|
+
|
122
|
+
if self.ssl_client_cert is not None:
|
123
|
+
# Load client-side certificate and private key
|
124
|
+
context.load_cert_chain(certfile=self.ssl_client_cert[0], keyfile=self.ssl_client_cert[1])
|
125
|
+
|
126
|
+
# Create a TCP socket
|
127
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
128
|
+
sock.setblocking(True)
|
129
|
+
sock.settimeout(30) # Connection timeout set to 30 seconds
|
130
|
+
# Wrap the socket with SSL/TLS
|
131
|
+
ssl_socket = context.wrap_socket(sock, server_hostname=self.tcp_address[0])
|
132
|
+
# Connect to the server
|
133
|
+
ssl_socket.connect(self.tcp_address)
|
134
|
+
# Disable Nagle's algorithm
|
135
|
+
ssl_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
136
|
+
# After connecting, set separate timeout for recv/send
|
137
|
+
# ssl_socket.settimeout(20) # 20 seconds for recv/send
|
138
|
+
return ssl_socket
|
139
|
+
|
140
|
+
def __close_socket(self) -> None:
|
141
|
+
"""Close socket connection"""
|
142
|
+
if self.__socket_client:
|
143
|
+
try:
|
144
|
+
self.__socket_client.close()
|
145
|
+
except Exception as e:
|
146
|
+
print(f"Error closing socket: {e}")
|
147
|
+
finally:
|
148
|
+
self.__socket_client = None
|
149
|
+
|
150
|
+
def __recvall(self, length: int) -> bytearray:
|
151
|
+
"""socket recv data with fixed length
|
152
|
+
https://stackoverflow.com/questions/64466530/using-a-custom-socket-recvall-function-works-only-if-thread-is-put-to-sleep
|
153
|
+
Args:
|
154
|
+
length (bytes): data length to be received
|
155
|
+
|
156
|
+
Returns:
|
157
|
+
bytearray: Received data
|
158
|
+
|
159
|
+
Raises:
|
160
|
+
EOFError: If connection closes prematurely
|
161
|
+
ValueError: If length is invalid
|
162
|
+
"""
|
163
|
+
if length <= 0:
|
164
|
+
raise ValueError(f"Invalid length: {length}")
|
165
|
+
|
166
|
+
# Pre-allocate buffer of exact size needed
|
167
|
+
buffer = bytearray(length)
|
168
|
+
view = memoryview(buffer)
|
169
|
+
bytes_received = 0
|
170
|
+
|
171
|
+
while bytes_received < length:
|
172
|
+
# Use recv_into to read directly into our buffer
|
173
|
+
chunk_size = self.__socket_client.recv_into(view[bytes_received:], length - bytes_received)
|
174
|
+
|
175
|
+
if chunk_size == 0:
|
176
|
+
raise EOFError("Connection closed by peer")
|
177
|
+
|
178
|
+
bytes_received += chunk_size
|
179
|
+
|
180
|
+
return buffer
|
181
|
+
|
182
|
+
def _request_http(self, method: AppMeshClient.Method, path: str, query: dict = None, header: dict = None, body=None) -> requests.Response:
|
183
|
+
"""TCP API
|
184
|
+
|
185
|
+
Args:
|
186
|
+
method (Method): AppMeshClient.Method.
|
187
|
+
path (str): URI patch str.
|
188
|
+
query (dict, optional): HTTP query parameters.
|
189
|
+
header (dict, optional): HTTP headers.
|
190
|
+
body (_type_, optional): object to send in the body of the :class:`Request`.
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
requests.Response: HTTP response
|
194
|
+
"""
|
195
|
+
|
196
|
+
if self.__socket_client is None:
|
197
|
+
self.__socket_client = self.__connect_socket()
|
198
|
+
|
199
|
+
appmesh_request = RequestMsg()
|
200
|
+
if super().jwt_token:
|
201
|
+
appmesh_request.headers["Authorization"] = "Bearer " + super().jwt_token
|
202
|
+
if super().forwarding_host and len(super().forwarding_host) > 0:
|
203
|
+
raise Exception("Not support forward request in TCP mode")
|
204
|
+
appmesh_request.headers[self.HTTP_HEADER_KEY_USER_AGENT] = self.HTTP_USER_AGENT_TCP
|
205
|
+
appmesh_request.uuid = str(uuid.uuid1())
|
206
|
+
appmesh_request.http_method = method.value
|
207
|
+
appmesh_request.request_uri = path
|
208
|
+
appmesh_request.client_addr = socket.gethostname()
|
209
|
+
if body:
|
210
|
+
if isinstance(body, dict) or isinstance(body, list):
|
211
|
+
appmesh_request.body = bytes(json.dumps(body, indent=2), self.ENCODING_UTF8)
|
212
|
+
elif isinstance(body, str):
|
213
|
+
appmesh_request.body = bytes(body, self.ENCODING_UTF8)
|
214
|
+
elif isinstance(body, bytes):
|
215
|
+
appmesh_request.body = body
|
216
|
+
else:
|
217
|
+
raise Exception(f"UnSupported body type: {type(body)}")
|
218
|
+
if header:
|
219
|
+
for k, v in header.items():
|
220
|
+
appmesh_request.headers[k] = v
|
221
|
+
if query:
|
222
|
+
for k, v in query.items():
|
223
|
+
appmesh_request.querys[k] = v
|
224
|
+
data = appmesh_request.serialize()
|
225
|
+
self.__socket_client.sendall(len(data).to_bytes(self.TCP_HEADER_LENGTH, byteorder="big", signed=False))
|
226
|
+
self.__socket_client.sendall(data)
|
227
|
+
|
228
|
+
# https://developers.google.com/protocol-buffers/docs/pythontutorial
|
229
|
+
# https://stackoverflow.com/questions/33913308/socket-module-how-to-send-integer
|
230
|
+
resp_data = self.__recvall(int.from_bytes(self.__recvall(self.TCP_HEADER_LENGTH), byteorder="big", signed=False))
|
231
|
+
if resp_data is None or len(resp_data) == 0:
|
232
|
+
self.__close_socket()
|
233
|
+
raise Exception("socket connection broken")
|
234
|
+
appmesh_resp = ResponseMsg().desirialize(resp_data)
|
235
|
+
response = requests.Response()
|
236
|
+
response.status_code = appmesh_resp.http_status
|
237
|
+
response.encoding = self.ENCODING_UTF8
|
238
|
+
response._content = appmesh_resp.body.encode(self.ENCODING_UTF8)
|
239
|
+
response.headers = appmesh_resp.headers
|
240
|
+
if appmesh_resp.body_msg_type:
|
241
|
+
response.headers["Content-Type"] = appmesh_resp.body_msg_type
|
242
|
+
return response
|
243
|
+
|
244
|
+
########################################
|
245
|
+
# File management
|
246
|
+
########################################
|
247
|
+
def file_download(self, remote_file: str, local_file: str, apply_file_attributes: bool = True) -> None:
|
248
|
+
"""Copy a remote file to local, the local file will have the same permission as the remote file
|
249
|
+
|
250
|
+
Args:
|
251
|
+
remote_file (str): the remote file path.
|
252
|
+
local_file (str): the local file path to be downloaded.
|
253
|
+
apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
|
254
|
+
"""
|
255
|
+
header = {"File-Path": remote_file}
|
256
|
+
header[self.HTTP_HEADER_KEY_X_RECV_FILE_SOCKET] = "true"
|
257
|
+
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header=header)
|
258
|
+
|
259
|
+
resp.raise_for_status()
|
260
|
+
if self.HTTP_HEADER_KEY_X_RECV_FILE_SOCKET not in resp.headers:
|
261
|
+
raise ValueError(f"Server did not respond with socket transfer option: {self.HTTP_HEADER_KEY_X_RECV_FILE_SOCKET}")
|
262
|
+
|
263
|
+
with open(local_file, "wb") as fp:
|
264
|
+
chunk_data = bytes()
|
265
|
+
chunk_size = int.from_bytes(self.__recvall(self.TCP_HEADER_LENGTH), byteorder="big", signed=False)
|
266
|
+
while chunk_size > 0:
|
267
|
+
chunk_data = self.__recvall(chunk_size)
|
268
|
+
if chunk_data is None or len(chunk_data) == 0:
|
269
|
+
self.__close_socket()
|
270
|
+
raise Exception("socket connection broken")
|
271
|
+
fp.write(chunk_data)
|
272
|
+
chunk_size = int.from_bytes(self.__recvall(self.TCP_HEADER_LENGTH), byteorder="big", signed=False)
|
273
|
+
|
274
|
+
if apply_file_attributes:
|
275
|
+
if "File-Mode" in resp.headers:
|
276
|
+
os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
|
277
|
+
if "File-User" in resp.headers and "File-Group" in resp.headers:
|
278
|
+
file_uid = int(resp.headers["File-User"])
|
279
|
+
file_gid = int(resp.headers["File-Group"])
|
280
|
+
try:
|
281
|
+
os.chown(path=local_file, uid=file_uid, gid=file_gid)
|
282
|
+
except PermissionError:
|
283
|
+
print(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
|
284
|
+
|
285
|
+
def file_upload(self, local_file: str, remote_file: str, apply_file_attributes: bool = True) -> None:
|
286
|
+
"""Upload a local file to the remote server, the remote file will have the same permission as the local file
|
287
|
+
|
288
|
+
Dependency:
|
289
|
+
sudo apt install python3-pip
|
290
|
+
pip3 install requests_toolbelt
|
291
|
+
|
292
|
+
Args:
|
293
|
+
local_file (str): the local file path.
|
294
|
+
remote_file (str): the target remote file to be uploaded.
|
295
|
+
apply_file_attributes (bool): whether to upload file attributes (permissions, owner, group) along with the file.
|
296
|
+
"""
|
297
|
+
if not os.path.exists(local_file):
|
298
|
+
raise FileNotFoundError(f"Local file not found: {local_file}")
|
299
|
+
|
300
|
+
with open(file=local_file, mode="rb") as fp:
|
301
|
+
header = {"File-Path": remote_file, "Content-Type": "text/plain"}
|
302
|
+
header[self.HTTP_HEADER_KEY_X_SEND_FILE_SOCKET] = "true"
|
303
|
+
|
304
|
+
if apply_file_attributes:
|
305
|
+
file_stat = os.stat(local_file)
|
306
|
+
header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
307
|
+
header["File-User"] = str(file_stat.st_uid)
|
308
|
+
header["File-Group"] = str(file_stat.st_gid)
|
309
|
+
|
310
|
+
# https://stackoverflow.com/questions/22567306/python-requests-file-upload
|
311
|
+
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/file/upload", header=header)
|
312
|
+
|
313
|
+
resp.raise_for_status()
|
314
|
+
if self.HTTP_HEADER_KEY_X_SEND_FILE_SOCKET not in resp.headers:
|
315
|
+
raise ValueError(f"Server did not respond with socket transfer option: {self.HTTP_HEADER_KEY_X_SEND_FILE_SOCKET}")
|
316
|
+
|
317
|
+
chunk_size = self.TCP_BLOCK_SIZE
|
318
|
+
while True:
|
319
|
+
chunk_data = fp.read(chunk_size)
|
320
|
+
if not chunk_data:
|
321
|
+
self.__socket_client.sendall((0).to_bytes(self.TCP_HEADER_LENGTH, byteorder="big", signed=False))
|
322
|
+
break
|
323
|
+
self.__socket_client.sendall(len(chunk_data).to_bytes(self.TCP_HEADER_LENGTH, byteorder="big", signed=False))
|
324
|
+
self.__socket_client.sendall(chunk_data)
|
325
|
+
|
326
|
+
|
327
|
+
class RequestMsg:
|
328
|
+
"""HTTP request message"""
|
329
|
+
|
330
|
+
uuid: str = ""
|
331
|
+
request_uri: str = ""
|
332
|
+
http_method: str = ""
|
333
|
+
client_addr: str = ""
|
334
|
+
body: bytes = b""
|
335
|
+
headers: dict = {}
|
336
|
+
querys: dict = {}
|
337
|
+
|
338
|
+
def serialize(self) -> bytes:
|
339
|
+
"""Serialize request message to bytes"""
|
340
|
+
# http://www.cnitblog.com/luckydmz/archive/2019/11/20/91959.html
|
341
|
+
self_dict = vars(self)
|
342
|
+
self_dict["headers"] = self.headers
|
343
|
+
self_dict["querys"] = self.querys
|
344
|
+
return msgpack.dumps(self_dict)
|
345
|
+
|
346
|
+
|
347
|
+
class ResponseMsg:
|
348
|
+
"""HTTP response message"""
|
349
|
+
|
350
|
+
uuid: str = ""
|
351
|
+
request_uri: str = ""
|
352
|
+
http_status: int = 0
|
353
|
+
body_msg_type: str = ""
|
354
|
+
body: str = ""
|
355
|
+
headers: dict = {}
|
356
|
+
|
357
|
+
def desirialize(self, buf: bytes):
|
358
|
+
"""Deserialize response message"""
|
359
|
+
dic = msgpack.unpackb(buf)
|
360
|
+
for k, v in dic.items():
|
361
|
+
setattr(self, k, v)
|
362
|
+
return self
|
@@ -0,0 +1,10 @@
|
|
1
|
+
appmesh/__init__.py,sha256=2-k6yrZr3TLHZ00YSiFHAYzsccSQ3eWZgoRs1eavZb8,442
|
2
|
+
appmesh/app.py,sha256=trPD1i7PFv7DTuy33Hr8-AC36r3h3XIBejdB3cUU8C8,10438
|
3
|
+
appmesh/app_output.py,sha256=JK_TMKgjvaw4n_ys_vmN5S4MyWVZpmD7NlKz_UyMIM8,1015
|
4
|
+
appmesh/app_run.py,sha256=D4j_SaA16_RtZ2-Ey6X4HIyngvLdfFHgyzYurDT1ATc,1753
|
5
|
+
appmesh/appmesh_client.py,sha256=9zysxEzSL3y2Csm7teLk7l6hknKzzTd-l4kWKeuuOj4,47571
|
6
|
+
appmesh/appmesh_client_tcp.py,sha256=BcPQcJqdPIWqP4vbRKHHEvlkkI83mqNfUse6AUGDbkE,16424
|
7
|
+
appmesh-1.3.7.dist-info/METADATA,sha256=rQoOicSO7Hz9lIocYszWWjpcpf65QzKFeILPyiB_tCA,11191
|
8
|
+
appmesh-1.3.7.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
|
9
|
+
appmesh-1.3.7.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
|
10
|
+
appmesh-1.3.7.dist-info/RECORD,,
|
appmesh-1.3.5.dist-info/RECORD
DELETED
@@ -1,6 +0,0 @@
|
|
1
|
-
appmesh/__init__.py,sha256=xRdXeFHEieRauuJZElbEBASgXG0ZzU1a5_0isAhM7Gw,11
|
2
|
-
appmesh/appmesh_client.py,sha256=bduPEDpI36XQyK_U9y3EvpZvO9CbpDeAB5402yf9qhU,69329
|
3
|
-
appmesh-1.3.5.dist-info/METADATA,sha256=VSkQis_Azjvw2FwjrK8qjOvYKw2WRNB37J2tYm_KEbw,11191
|
4
|
-
appmesh-1.3.5.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
|
5
|
-
appmesh-1.3.5.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
|
6
|
-
appmesh-1.3.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|