appmesh 1.4.5__py3-none-any.whl → 1.4.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/http_client.py CHANGED
@@ -14,6 +14,7 @@ import requests
14
14
  from .app import App
15
15
  from .app_run import AppRun
16
16
  from .app_output import AppOutput
17
+ from .keycloak import KeycloakClient
17
18
 
18
19
 
19
20
  class AppMeshClient(metaclass=abc.ABCMeta):
@@ -131,6 +132,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
131
132
  rest_ssl_client_cert=(DEFAULT_SSL_CLIENT_CERT_PATH, DEFAULT_SSL_CLIENT_KEY_PATH) if os.path.exists(DEFAULT_SSL_CLIENT_CERT_PATH) else None,
132
133
  rest_timeout=(60, 300),
133
134
  jwt_token=None,
135
+ oauth2_config=None,
134
136
  ):
135
137
  """Initialize an App Mesh HTTP client for interacting with the App Mesh server via secure HTTPS.
136
138
 
@@ -152,8 +154,14 @@ class AppMeshClient(metaclass=abc.ABCMeta):
152
154
 
153
155
  jwt_token (str, optional): JWT token for API authentication, used in headers to authorize requests where required.
154
156
 
157
+ oauth2 (Dict[str, Any], optional): Keycloak configuration for oauth2 authentication:
158
+ - server_url: Keycloak server URL (e.g. "https://keycloak.example.com")
159
+ - realm: Keycloak realm
160
+ - client_id: Keycloak client ID
161
+ - client_secret: Keycloak client secret (optional)
155
162
  """
156
163
 
164
+ self.session = requests.Session()
157
165
  self.server_url = rest_url
158
166
  self._jwt_token = jwt_token
159
167
  self.ssl_verify = rest_ssl_verify
@@ -161,6 +169,33 @@ class AppMeshClient(metaclass=abc.ABCMeta):
161
169
  self.rest_timeout = rest_timeout
162
170
  self._forward_to = None
163
171
 
172
+ # Keycloak integration
173
+ self._keycloak_client = None
174
+ if oauth2_config:
175
+ self._keycloak_client = KeycloakClient(
176
+ server_url=oauth2_config.get("server_url"),
177
+ realm=oauth2_config.get("realm"),
178
+ client_id=oauth2_config.get("client_id"),
179
+ client_secret=oauth2_config.get("client_secret"),
180
+ ssl_verify=oauth2_config.get("ssl_verify", self.ssl_verify),
181
+ timeout=oauth2_config.get("timeout", (30, 60)),
182
+ )
183
+
184
+ def close(self):
185
+ """Close the session and release resources."""
186
+ if hasattr(self, 'session'):
187
+ self.session.close()
188
+ if self._keycloak_client and hasattr(self._keycloak_client, 'close'):
189
+ self._keycloak_client.close()
190
+
191
+ def __enter__(self):
192
+ """Support for context manager protocol."""
193
+ return self
194
+
195
+ def __exit__(self, exc_type, exc_val, exc_tb):
196
+ """Support for context manager protocol, ensuring resources are released."""
197
+ self.close()
198
+
164
199
  @property
165
200
  def jwt_token(self) -> str:
166
201
  """Get the current JWT (JSON Web Token) used for authentication.
@@ -266,6 +301,12 @@ class AppMeshClient(metaclass=abc.ABCMeta):
266
301
  Returns:
267
302
  str: JWT token.
268
303
  """
304
+ # Keycloak authentication if configured
305
+ if self._keycloak_client:
306
+ self.jwt_token = self._keycloak_client.authenticate(user_name, user_pwd)
307
+ return self.jwt_token
308
+
309
+ # Standard App Mesh authentication
269
310
  self.jwt_token = None
270
311
  resp = self._request_http(
271
312
  AppMeshClient.Method.POST,
@@ -279,38 +320,57 @@ class AppMeshClient(metaclass=abc.ABCMeta):
279
320
  if resp.status_code == HTTPStatus.OK:
280
321
  if "Access-Token" in resp.json():
281
322
  self.jwt_token = resp.json()["Access-Token"]
282
- elif resp.status_code == HTTPStatus.UNAUTHORIZED and "Totp-Challenge" in resp.json():
323
+ elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED and "Totp-Challenge" in resp.json():
283
324
  challenge = resp.json()["Totp-Challenge"]
284
- resp = self._request_http(
285
- AppMeshClient.Method.POST,
286
- path="/appmesh/totp/validate",
287
- header={
288
- "Username": base64.b64encode(user_name.encode()).decode(),
289
- "Totp-Challenge": base64.b64encode(challenge.encode()).decode(),
290
- "Totp": totp_code,
291
- "Expire-Seconds": self._parse_duration(timeout_seconds),
292
- },
293
- )
294
- if resp.status_code == HTTPStatus.OK:
295
- if "Access-Token" in resp.json():
296
- self.jwt_token = resp.json()["Access-Token"]
297
- else:
298
- raise Exception(resp.text)
325
+ self.validate_totp(user_name, challenge, totp_code, timeout_seconds)
299
326
  else:
300
327
  raise Exception(resp.text)
301
328
  return self.jwt_token
302
329
 
330
+ def validate_totp(self, username: str, challenge: str, code: str, timeout: Union[int, str] = DURATION_ONE_WEEK_ISO) -> str:
331
+ """Validate TOTP challenge and obtain a new JWT token.
332
+
333
+ Args:
334
+ username (str): Username to validate
335
+ challenge (str): Challenge string from server
336
+ code (str): TOTP code to validate
337
+ timeout (Union[int, str], optional): Token expiry timeout.
338
+ Accepts ISO 8601 duration format (e.g., 'P1Y2M3DT4H5M6S', 'P1W') or seconds.
339
+ Defaults to DURATION_ONE_WEEK_ISO.
340
+
341
+ Returns:
342
+ str: New JWT token if validation succeeds
343
+
344
+ Raises:
345
+ Exception: If validation fails or server returns error
346
+ """
347
+ resp = self._request_http(
348
+ AppMeshClient.Method.POST,
349
+ path="/appmesh/totp/validate",
350
+ header={
351
+ "Username": base64.b64encode(username.encode()).decode(),
352
+ "Totp": code,
353
+ "Totp-Challenge": base64.b64encode(challenge.encode()).decode(),
354
+ "Expire-Seconds": self._parse_duration(timeout),
355
+ },
356
+ )
357
+ if resp.status_code == HTTPStatus.OK:
358
+ if "Access-Token" in resp.json():
359
+ self.jwt_token = resp.json()["Access-Token"]
360
+ return self.jwt_token
361
+ raise Exception(resp.text)
362
+
303
363
  def logoff(self) -> bool:
304
364
  """Log out of the current session from the server.
305
365
 
306
366
  Returns:
307
367
  bool: logoff success or failure.
308
368
  """
309
- resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/self/logoff")
310
- if resp.status_code != HTTPStatus.OK:
311
- raise Exception(resp.text)
312
- self.jwt_token = None
313
- return resp.status_code == HTTPStatus.OK
369
+ if self.jwt_token:
370
+ resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/self/logoff")
371
+ self.jwt_token = None
372
+ return resp.status_code == HTTPStatus.OK
373
+ return True
314
374
 
315
375
  def authentication(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
316
376
  """Deprecated: Use authenticate() instead."""
@@ -351,6 +411,12 @@ class AppMeshClient(metaclass=abc.ABCMeta):
351
411
  Returns:
352
412
  str: The new JWT token if renew success, the old token will be blocked.
353
413
  """
414
+ # Refresh the Keycloak token
415
+ if self._keycloak_client:
416
+ self.jwt_token = self._keycloak_client.refresh_tokens()
417
+ return self.jwt_token
418
+
419
+ # Refresh the App Mesh token
354
420
  assert self.jwt_token
355
421
  resp = self._request_http(
356
422
  AppMeshClient.Method.POST,
@@ -934,7 +1000,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
934
1000
 
935
1001
  with open(file=local_file, mode="rb") as fp:
936
1002
  encoder = MultipartEncoder(fields={"filename": os.path.basename(remote_file), "file": ("filename", fp, "application/octet-stream")})
937
- header = {"File-Path": remote_file, "Content-Type": encoder.content_type}
1003
+ header = {"File-Path": parse.quote(remote_file), "Content-Type": encoder.content_type}
938
1004
 
939
1005
  # Include file attributes (permissions, owner, group) if requested
940
1006
  if apply_file_attributes:
@@ -1101,6 +1167,10 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1101
1167
  Returns:
1102
1168
  requests.Response: HTTP response
1103
1169
  """
1170
+ # Try to refresh token via Keycloak if using Keycloak and token needs refreshing
1171
+ if self._keycloak_client and self.jwt_token and not self._keycloak_client.validate_token():
1172
+ self.jwt_token = self._keycloak_client.get_active_token()
1173
+
1104
1174
  rest_url = parse.urljoin(self.server_url, path)
1105
1175
 
1106
1176
  header = {} if header is None else header
@@ -1114,16 +1184,16 @@ class AppMeshClient(metaclass=abc.ABCMeta):
1114
1184
  header[self.HTTP_HEADER_KEY_USER_AGENT] = self.HTTP_USER_AGENT
1115
1185
 
1116
1186
  if method is AppMeshClient.Method.GET:
1117
- return requests.get(url=rest_url, params=query, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1187
+ return self.session.get(url=rest_url, params=query, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1118
1188
  elif method is AppMeshClient.Method.POST:
1119
- return requests.post(
1189
+ return self.session.post(
1120
1190
  url=rest_url, params=query, headers=header, data=json.dumps(body) if type(body) in (dict, list) else body, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout
1121
1191
  )
1122
1192
  elif method is AppMeshClient.Method.POST_STREAM:
1123
- return requests.post(url=rest_url, params=query, headers=header, data=body, cert=self.ssl_client_cert, verify=self.ssl_verify, stream=True, timeout=self.rest_timeout)
1193
+ return self.session.post(url=rest_url, params=query, headers=header, data=body, cert=self.ssl_client_cert, verify=self.ssl_verify, stream=True, timeout=self.rest_timeout)
1124
1194
  elif method is AppMeshClient.Method.DELETE:
1125
- return requests.delete(url=rest_url, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1195
+ return self.session.delete(url=rest_url, headers=header, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1126
1196
  elif method is AppMeshClient.Method.PUT:
1127
- return requests.put(url=rest_url, params=query, headers=header, json=body, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1197
+ return self.session.put(url=rest_url, params=query, headers=header, json=body, cert=self.ssl_client_cert, verify=self.ssl_verify, timeout=self.rest_timeout)
1128
1198
  else:
1129
1199
  raise Exception("Invalid http method", method)
appmesh/keycloak.py ADDED
@@ -0,0 +1,232 @@
1
+ # Keycloak authentication client for App Mesh Python SDK
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+ from typing import Dict, Optional, Tuple, Any
7
+ import requests
8
+
9
+
10
+ class KeycloakClient:
11
+ """
12
+ Client for authenticating with Keycloak and obtaining tokens for App Mesh.
13
+
14
+ This class handles the OAuth2/OIDC workflow with Keycloak, including:
15
+ - Direct authentication with username/password
16
+ - Token refresh
17
+ - Token validation
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ server_url: str,
23
+ realm: str,
24
+ client_id: str,
25
+ client_secret: Optional[str] = None,
26
+ ssl_verify: bool = True,
27
+ timeout: Tuple[int, int] = (10, 60),
28
+ token_refresh_threshold: int = 30,
29
+ ):
30
+ """Initialize Keycloak client.
31
+
32
+ Args:
33
+ server_url (str): Keycloak server URL (e.g. https://keycloak.example.com/auth)
34
+ realm (str): Keycloak realm name
35
+ client_id (str): Client ID registered in Keycloak
36
+ client_secret (Optional[str], optional): Client secret if using confidential client. Defaults to None.
37
+ ssl_verify (bool, optional): Verify SSL certificates. Defaults to True.
38
+ timeout (Tuple[int, int], optional): Connection and read timeouts. Defaults to (10, 60).
39
+ token_refresh_threshold (int, optional): Seconds before token expiry to trigger refresh. Defaults to 30.
40
+ """
41
+ self.server_url = server_url.rstrip("/")
42
+ self.realm = realm
43
+ self.client_id = client_id
44
+ self.client_secret = client_secret
45
+ self.ssl_verify = ssl_verify
46
+ self.timeout = timeout
47
+ self.token_refresh_threshold = token_refresh_threshold
48
+
49
+ # Token storage
50
+ self.access_token = None
51
+ self.refresh_token = None
52
+ self.token_expires_at = 0
53
+
54
+ # Construct the token endpoint URL
55
+ self.token_endpoint = f"{self.server_url}/realms/{self.realm}/protocol/openid-connect/token"
56
+ self.userinfo_endpoint = f"{self.server_url}/realms/{self.realm}/protocol/openid-connect/userinfo"
57
+
58
+ # Create a session for connection pooling
59
+ self.session = requests.Session()
60
+
61
+ def authenticate(self, username: str, password: str) -> str:
62
+ """Authenticate with username and password.
63
+
64
+ Args:
65
+ username (str): Username
66
+ password (str): Password
67
+
68
+ Returns:
69
+ str: Access token
70
+
71
+ Raises:
72
+ Exception: If authentication fails
73
+ """
74
+ data = {
75
+ "client_id": self.client_id,
76
+ "grant_type": "password",
77
+ "username": username,
78
+ "password": password,
79
+ }
80
+
81
+ if self.client_secret:
82
+ data["client_secret"] = self.client_secret
83
+
84
+ try:
85
+ response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
86
+
87
+ if response.status_code != 200:
88
+ raise Exception(f"Authentication failed: {response.text}")
89
+
90
+ token_data = response.json()
91
+ self._update_token_data(token_data)
92
+ return self.access_token
93
+
94
+ except requests.RequestException as e:
95
+ raise Exception(f"Failed to connect to Keycloak: {str(e)}")
96
+
97
+ def refresh_tokens(self) -> str:
98
+ """Refresh the access token using the refresh token.
99
+
100
+ Returns:
101
+ str: New access token
102
+
103
+ Raises:
104
+ Exception: If refresh fails
105
+ """
106
+ if not self.refresh_token:
107
+ raise Exception("No refresh token available")
108
+
109
+ data = {
110
+ "client_id": self.client_id,
111
+ "grant_type": "refresh_token",
112
+ "refresh_token": self.refresh_token,
113
+ }
114
+
115
+ if self.client_secret:
116
+ data["client_secret"] = self.client_secret
117
+
118
+ try:
119
+ response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
120
+
121
+ if response.status_code != 200:
122
+ raise Exception(f"Token refresh failed: {response.text}")
123
+
124
+ token_data = response.json()
125
+ self._update_token_data(token_data)
126
+ return self.access_token
127
+
128
+ except requests.RequestException as e:
129
+ raise Exception(f"Failed to connect to Keycloak: {str(e)}")
130
+
131
+ def validate_token(self) -> bool:
132
+ """Check if the current token is valid and not expired.
133
+
134
+ Returns:
135
+ bool: True if valid, False otherwise
136
+ """
137
+ if not self.access_token:
138
+ return False
139
+
140
+ # Check if the token is expired based on our local expiry time
141
+ if time.time() > self.token_expires_at:
142
+ return False
143
+
144
+ return True
145
+
146
+ def get_active_token(self) -> str:
147
+ """Get a valid access token, refreshing if necessary.
148
+
149
+ Returns:
150
+ str: Valid access token
151
+
152
+ Raises:
153
+ Exception: If no valid token can be obtained
154
+ """
155
+ if self.validate_token():
156
+ return self.access_token
157
+
158
+ if self.refresh_token:
159
+ try:
160
+ return self.refresh_tokens()
161
+ except Exception:
162
+ pass
163
+
164
+ raise Exception("No valid token available and unable to refresh")
165
+
166
+ def get_user_info(self) -> Dict[str, Any]:
167
+ """Get information about the authenticated user.
168
+
169
+ Returns:
170
+ Dict[str, Any]: User information
171
+
172
+ Raises:
173
+ Exception: If request fails
174
+ """
175
+ if not self.access_token:
176
+ raise Exception("Not authenticated")
177
+
178
+ headers = {"Authorization": f"Bearer {self.access_token}"}
179
+
180
+ try:
181
+ response = self.session.get(self.userinfo_endpoint, headers=headers, verify=self.ssl_verify, timeout=self.timeout)
182
+
183
+ if response.status_code != 200:
184
+ raise Exception(f"Failed to get user info: {response.text}")
185
+
186
+ return response.json()
187
+
188
+ except requests.RequestException as e:
189
+ raise Exception(f"Failed to connect to Keycloak: {str(e)}")
190
+
191
+ def _update_token_data(self, token_data: Dict[str, Any]) -> None:
192
+ """Update the stored token data.
193
+
194
+ Args:
195
+ token_data (Dict[str, Any]): Token data from Keycloak
196
+ """
197
+ self.access_token = token_data["access_token"]
198
+ self.refresh_token = token_data.get("refresh_token")
199
+
200
+ # Calculate token expiration time with configurable buffer
201
+ expires_in = token_data.get("expires_in", 300)
202
+ self.token_expires_at = time.time() + expires_in - self.token_refresh_threshold
203
+
204
+ def close(self) -> None:
205
+ """Close the session and release resources."""
206
+ if hasattr(self, 'session'):
207
+ self.session.close()
208
+
209
+ @staticmethod
210
+ def decode_token(token: str) -> Dict[str, Any]:
211
+ """Decode a JWT token without verification.
212
+
213
+ Args:
214
+ token (str): JWT token
215
+
216
+ Returns:
217
+ Dict[str, Any]: Decoded token payload
218
+ """
219
+ parts = token.split(".")
220
+ if len(parts) != 3:
221
+ raise Exception("Invalid token format")
222
+
223
+ # Decode the payload (second part)
224
+ payload = parts[1]
225
+ # Add padding if necessary
226
+ payload += "=" * (4 - len(payload) % 4) if len(payload) % 4 else ""
227
+
228
+ try:
229
+ decoded = base64.b64decode(payload)
230
+ return json.loads(decoded)
231
+ except Exception as e:
232
+ raise Exception(f"Failed to decode token: {str(e)}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: appmesh
3
- Version: 1.4.5
3
+ Version: 1.4.7
4
4
  Summary: Client SDK for App Mesh
5
5
  Home-page: https://github.com/laoshanxi/app-mesh
6
6
  Author: laoshanxi
@@ -28,13 +28,14 @@ Dynamic: requires-dist
28
28
  Dynamic: requires-python
29
29
  Dynamic: summary
30
30
 
31
- [![language.badge]][language.url] [![standard.badge]][standard.url] [![release.badge]][release.url] [![pypi.badge]][pypi.url] [![unittest.badge]][unittest.url] [![docker.badge]][docker.url] [![cockpit.badge]][cockpit.url]
31
+ [![language.badge]][language.url] [![standard.badge]][standard.url] [![unittest.badge]][unittest.url] [![docker.badge]][docker.url] [![cockpit.badge]][cockpit.url]
32
32
  [![Documentation Status](https://readthedocs.org/projects/app-mesh/badge/?version=latest)](https://app-mesh.readthedocs.io/en/latest/?badge=latest) [![Join the chat at https://gitter.im/app-mesh/community](https://badges.gitter.im/app-mesh/community.svg)](https://gitter.im/app-mesh/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
33
33
  <a href="https://scan.coverity.com/projects/laoshanxi-app-mesh">
34
34
  <img alt="Coverity Scan Build Status"
35
35
  src="https://img.shields.io/coverity/scan/21528.svg"/>
36
36
  </a>
37
37
  [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/laoshanxi/app-mesh/badge)](https://api.securityscorecards.dev/projects/github.com/laoshanxi/app-mesh)
38
+ [![release.badge]][release.url] [![pypi.badge]][pypi.url] [![npm.badge]][npm.url]
38
39
 
39
40
  # App Mesh: Advanced Application Management Platform
40
41
 
@@ -148,7 +149,7 @@ Refer to the [Installation doc](https://app-mesh.readthedocs.io/en/latest/Instal
148
149
  [standard.url]: https://en.wikipedia.org/wiki/C%2B%2B#Standardization
149
150
  [standard.badge]: https://img.shields.io/badge/C%2B%2B-11%2F14%2F17-blue.svg
150
151
  [release.url]: https://github.com/laoshanxi/app-mesh/releases
151
- [release.badge]: https://img.shields.io/github/v/release/laoshanxi/app-mesh.svg
152
+ [release.badge]: https://img.shields.io/github/v/release/laoshanxi/app-mesh?label=Github%20package
152
153
  [docker.url]: https://hub.docker.com/repository/docker/laoshanxi/appmesh
153
154
  [docker.badge]: https://img.shields.io/docker/pulls/laoshanxi/appmesh.svg
154
155
  [cockpit.url]: https://github.com/laoshanxi/app-mesh-ui
@@ -157,3 +158,5 @@ Refer to the [Installation doc](https://app-mesh.readthedocs.io/en/latest/Instal
157
158
  [unittest.badge]: https://img.shields.io/badge/UnitTest-Catch2-blue?logo=appveyor
158
159
  [pypi.badge]: https://img.shields.io/pypi/v/appmesh?label=PyPI%3Aappmesh
159
160
  [pypi.url]: https://pypi.org/project/appmesh/
161
+ [npm.badge]: https://img.shields.io/npm/v/appmesh?label=npm%3Aappmesh
162
+ [npm.url]: https://www.npmjs.com/package/appmesh
@@ -3,11 +3,12 @@ appmesh/app.py,sha256=9Q-SOOej-MH13BU5Dv2iTa-p-sECCJQp6ZX9DjWWmwE,10526
3
3
  appmesh/app_output.py,sha256=JK_TMKgjvaw4n_ys_vmN5S4MyWVZpmD7NlKz_UyMIM8,1015
4
4
  appmesh/app_run.py,sha256=9ISKGZ3k3kkbQvSsPfRfkOLqD9xhbqNOM7ork9F4w9c,1712
5
5
  appmesh/appmesh_client.py,sha256=0ltkqHZUq094gKneYmC0bEZCP0X9kHTp9fccKdWFWP0,339
6
- appmesh/http_client.py,sha256=dsQfcjC3U429yDJHhP5SFsoySTDbyRM3rToxi7ooGJA,44363
6
+ appmesh/http_client.py,sha256=dtrZryrkJ-dmT_NQE5v0fZPE00-PCD9VB9xL1Wu8ORI,47403
7
+ appmesh/keycloak.py,sha256=siyifDaH3EASIti8s0DzxBo477m8eCRmzKwK6dqGRDY,7497
7
8
  appmesh/tcp_client.py,sha256=RkHl5s8jE333BJOgxJqJ_fvjbdRQza7ciV49vLT6YO4,10923
8
9
  appmesh/tcp_messages.py,sha256=w1Kehz_aX4X2CYAUsy9mFVJRhxnLQwwc6L58W4YkQqs,969
9
10
  appmesh/tcp_transport.py,sha256=UMGby2oKV4k7lyXZUMSOe2Je34fb1w7nTkxEpatKLKg,7256
10
- appmesh-1.4.5.dist-info/METADATA,sha256=ozlyIACS7JpSX1Va_RS6tE1WY3h1AR0xqAen1R1Bu08,11501
11
- appmesh-1.4.5.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
12
- appmesh-1.4.5.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
13
- appmesh-1.4.5.dist-info/RECORD,,
11
+ appmesh-1.4.7.dist-info/METADATA,sha256=Tinrv6mkVtpVQi-ZZm_rTpCWLu-NoTEUvL-dBAQVaZg,11663
12
+ appmesh-1.4.7.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
13
+ appmesh-1.4.7.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
14
+ appmesh-1.4.7.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (75.8.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5