exchange-keyshare 0.1.0__py3-none-any.whl → 0.1.2__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.
exchange_keyshare/cli.py CHANGED
@@ -4,8 +4,10 @@ from pathlib import Path
4
4
 
5
5
  import click
6
6
 
7
+ from exchange_keyshare.commands.config import config
7
8
  from exchange_keyshare.commands.keys import keys
8
9
  from exchange_keyshare.commands.setup import setup
10
+ from exchange_keyshare.commands.teardown import teardown
9
11
  from exchange_keyshare.config import Config
10
12
 
11
13
 
@@ -30,6 +32,8 @@ def main(ctx: click.Context, config_path: str | None) -> None:
30
32
 
31
33
 
32
34
  main.add_command(setup)
35
+ main.add_command(config)
36
+ main.add_command(teardown)
33
37
  main.add_command(keys)
34
38
 
35
39
 
@@ -0,0 +1,34 @@
1
+ """Config command for displaying current configuration."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from exchange_keyshare.config import Config
8
+
9
+
10
+ @click.command()
11
+ @click.pass_context
12
+ def config(ctx: click.Context) -> None:
13
+ """Display current configuration."""
14
+ cfg: Config = ctx.obj["config"]
15
+ console = Console()
16
+
17
+ if not cfg.stack_name:
18
+ console.print("Not configured. Run [cyan]exchange-keyshare setup[/cyan] first.")
19
+ return
20
+
21
+ table = Table(show_header=False, box=None)
22
+ table.add_column("Key", style="dim")
23
+ table.add_column("Value", style="cyan")
24
+
25
+ table.add_row("Bucket", cfg.bucket or "")
26
+ table.add_row("Region", cfg.region or "")
27
+ table.add_row("Stack Name", cfg.stack_name or "")
28
+ table.add_row("Stack ID", cfg.stack_id or "")
29
+ table.add_row("Role ARN", cfg.role_arn or "")
30
+ table.add_row("External ID", cfg.external_id or "")
31
+ table.add_row("KMS Key ARN", cfg.kms_key_arn or "")
32
+ table.add_row("Config Path", str(cfg.config_path))
33
+
34
+ console.print(table)
@@ -155,6 +155,7 @@ def setup(ctx: click.Context) -> None:
155
155
  config.bucket = result.bucket
156
156
  config.region = result.region
157
157
  config.stack_name = result.stack_name
158
+ config.stack_id = result.stack_id
158
159
  config.role_arn = result.role_arn
159
160
  config.external_id = result.external_id
160
161
  config.kms_key_arn = result.kms_key_arn
@@ -0,0 +1,208 @@
1
+ """Teardown command for deleting AWS infrastructure."""
2
+
3
+ import click
4
+ from rich.console import Console, Group, RenderableType
5
+ from rich.live import Live
6
+ from rich.panel import Panel
7
+ from rich.spinner import Spinner
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from exchange_keyshare.config import Config
12
+ from exchange_keyshare.keys import CredentialInfo, list_credentials
13
+ from exchange_keyshare.setup import (
14
+ ResourceStatus,
15
+ StackProgress,
16
+ get_friendly_type,
17
+ get_stack_console_url,
18
+ poll_stack_deletion,
19
+ start_stack_deletion,
20
+ )
21
+
22
+
23
+ def get_delete_status_style(status: str) -> str:
24
+ """Get rich style for a deletion status."""
25
+ if "DELETE_COMPLETE" in status or "DELETE_SKIPPED" in status:
26
+ return "green"
27
+ elif "IN_PROGRESS" in status:
28
+ return "yellow"
29
+ elif "FAILED" in status:
30
+ return "red"
31
+ return "white"
32
+
33
+
34
+ def get_delete_status_icon(status: str) -> str:
35
+ """Get icon for a deletion status."""
36
+ if "DELETE_COMPLETE" in status or "DELETE_SKIPPED" in status:
37
+ return "[green]✓[/green]"
38
+ elif "IN_PROGRESS" in status:
39
+ return "[yellow]⋯[/yellow]"
40
+ elif "FAILED" in status:
41
+ return "[red]✗[/red]"
42
+ return " "
43
+
44
+
45
+ def build_deletion_progress_display(progress: StackProgress) -> RenderableType:
46
+ """Build a rich display showing resource deletion progress with spinner."""
47
+ table = Table(show_header=True, header_style="bold", box=None)
48
+ table.add_column("", width=2)
49
+ table.add_column("Resource", style="cyan", min_width=25)
50
+ table.add_column("Type", style="dim")
51
+ table.add_column("Status")
52
+
53
+ # Sort resources: in_progress first, then by name
54
+ def sort_key(item: tuple[str, ResourceStatus]) -> tuple[int, str]:
55
+ _, r = item
56
+ if "IN_PROGRESS" in r.status:
57
+ return (0, r.logical_id)
58
+ elif "COMPLETE" in r.status or "SKIPPED" in r.status:
59
+ return (2, r.logical_id)
60
+ else:
61
+ return (1, r.logical_id)
62
+
63
+ sorted_resources = sorted(progress.resources.items(), key=sort_key)
64
+
65
+ for _logical_id, resource in sorted_resources:
66
+ icon = get_delete_status_icon(resource.status)
67
+ style = get_delete_status_style(resource.status)
68
+ friendly_type = get_friendly_type(resource.resource_type)
69
+
70
+ # Simplify status text
71
+ status_text = resource.status.replace("_", " ").title()
72
+
73
+ table.add_row(
74
+ icon,
75
+ resource.logical_id,
76
+ friendly_type,
77
+ f"[{style}]{status_text}[/{style}]",
78
+ )
79
+
80
+ # Add spinner header if still in progress
81
+ if not progress.is_complete and not progress.is_failed:
82
+ spinner = Spinner("dots", text=Text(" Deleting infrastructure...", style="bold"))
83
+ return Group(spinner, Text(""), table)
84
+
85
+ return table
86
+
87
+
88
+ @click.command()
89
+ @click.pass_context
90
+ def teardown(ctx: click.Context) -> None:
91
+ """Delete AWS infrastructure and revoke access.
92
+
93
+ This permanently deletes all infrastructure created by setup.
94
+ The S3 bucket and KMS key are retained but access is revoked.
95
+ """
96
+ cfg: Config = ctx.obj["config"]
97
+ console = Console()
98
+
99
+ if not cfg.stack_name:
100
+ console.print("Nothing to teardown. Run [cyan]exchange-keyshare setup[/cyan] first.")
101
+ return
102
+
103
+ region = cfg.region or "us-east-1"
104
+
105
+ # Try to list existing credentials (gracefully handle failure)
106
+ credentials: list[CredentialInfo] = []
107
+ if cfg.bucket and cfg.region:
108
+ try:
109
+ credentials = list_credentials(cfg.bucket, cfg.region)
110
+ except Exception:
111
+ pass # Gracefully ignore - permissions may already be revoked
112
+
113
+ # Build credential list for warning
114
+ cred_text = ""
115
+ if credentials:
116
+ cred_text = "\n[bold]Credentials that will become inaccessible:[/bold]\n"
117
+ for cred in credentials:
118
+ cred_text += f" - [cyan]{cred.exchange}[/cyan]\n"
119
+ if cred.pairs:
120
+ cred_text += f" Pairs: {', '.join(cred.pairs)}\n"
121
+ if cred.labels:
122
+ labels_str = ", ".join(f"{lbl['key']}={lbl['value']}" for lbl in cred.labels)
123
+ cred_text += f" Labels: {labels_str}\n"
124
+ cred_text += "\n"
125
+
126
+ # Build console URL if we have a stack ID
127
+ console_url = ""
128
+ if cfg.stack_id:
129
+ console_url = get_stack_console_url(cfg.stack_id, region)
130
+
131
+ # Show scary warning with specific resources
132
+ manual_cmd = f"aws cloudformation delete-stack --stack-name {cfg.stack_name} --region {region}"
133
+
134
+ warning = Panel(
135
+ "[bold red]WARNING: This action cannot be undone![/bold red]\n\n"
136
+ "[bold]Resources that will be DELETED:[/bold]\n"
137
+ f" - IAM Role: [cyan]{cfg.role_arn}[/cyan]\n"
138
+ f" - KMS Key Alias: [cyan]alias/{cfg.bucket}[/cyan]\n"
139
+ " - S3 Bucket Policies\n\n"
140
+ "[bold]Resources that will be RETAINED (but inaccessible):[/bold]\n"
141
+ f" - S3 Bucket: [cyan]{cfg.bucket}[/cyan]\n"
142
+ f" - KMS Key: [cyan]{cfg.kms_key_arn}[/cyan]\n"
143
+ f"{cred_text}\n"
144
+ f"Stack: [cyan]{cfg.stack_name}[/cyan]\n"
145
+ f"Region: [cyan]{region}[/cyan]\n\n"
146
+ f"[dim]Manual command: {manual_cmd}[/dim]",
147
+ title="[bold red]Teardown Infrastructure[/bold red]",
148
+ border_style="red",
149
+ )
150
+ console.print(warning)
151
+ console.print()
152
+
153
+ # Require typing the stack name to confirm
154
+ console.print(f"To confirm, type the stack name: [bold]{cfg.stack_name}[/bold]")
155
+ confirmation = click.prompt("", default="", show_default=False)
156
+
157
+ if confirmation != cfg.stack_name:
158
+ console.print("[yellow]Aborted.[/yellow] Stack name did not match.")
159
+ raise SystemExit(1)
160
+
161
+ console.print()
162
+
163
+ # Show console URL before starting deletion
164
+ if console_url:
165
+ console.print(f"[dim]Monitor progress in AWS Console:[/dim]")
166
+ console.print(f"[link={console_url}]{console_url}[/link]")
167
+ console.print()
168
+ console.print("[dim]You can safely cancel this command (Ctrl+C) - deletion will continue in AWS.[/dim]")
169
+ console.print()
170
+
171
+ # Start deletion and poll with live display
172
+ try:
173
+ cfn = start_stack_deletion(cfg.stack_name, region)
174
+ except Exception as e:
175
+ console.print(f"[red]Error starting deletion: {e}[/red]")
176
+ raise SystemExit(1)
177
+
178
+ final_progress: StackProgress | None = None
179
+
180
+ try:
181
+ with Live(console=console, refresh_per_second=10) as live:
182
+ for progress in poll_stack_deletion(cfg.stack_name, cfn):
183
+ display = build_deletion_progress_display(progress)
184
+ live.update(display)
185
+ final_progress = progress
186
+
187
+ if progress.is_complete or progress.is_failed:
188
+ break
189
+ except KeyboardInterrupt:
190
+ console.print()
191
+ console.print("[yellow]Interrupted.[/yellow] Deletion continues in AWS.")
192
+ if console_url:
193
+ console.print(f"[dim]Monitor progress:[/dim] {console_url}")
194
+ raise SystemExit(0)
195
+
196
+ if final_progress is None or final_progress.is_failed:
197
+ console.print()
198
+ console.print(f"[red]Error: {final_progress.failure_reason if final_progress else 'Unknown error'}[/red]")
199
+ if console_url:
200
+ console.print(f"[dim]Check AWS Console:[/dim] {console_url}")
201
+ raise SystemExit(1)
202
+
203
+ # Delete config file
204
+ if cfg.config_path.exists():
205
+ cfg.config_path.unlink()
206
+
207
+ console.print()
208
+ console.print("[green]Teardown complete.[/green] All access has been revoked.")
@@ -26,6 +26,7 @@ class Config:
26
26
  bucket: str | None = None
27
27
  region: str | None = None
28
28
  stack_name: str | None = None
29
+ stack_id: str | None = None
29
30
  role_arn: str | None = None
30
31
  external_id: str | None = None
31
32
  kms_key_arn: str | None = None
@@ -36,6 +37,7 @@ class Config:
36
37
  self.bucket = data.get("bucket")
37
38
  self.region = data.get("region")
38
39
  self.stack_name = data.get("stack_name")
40
+ self.stack_id = data.get("stack_id")
39
41
  self.role_arn = data.get("role_arn")
40
42
  self.external_id = data.get("external_id")
41
43
  self.kms_key_arn = data.get("kms_key_arn")
@@ -49,6 +51,8 @@ class Config:
49
51
  data["region"] = self.region
50
52
  if self.stack_name:
51
53
  data["stack_name"] = self.stack_name
54
+ if self.stack_id:
55
+ data["stack_id"] = self.stack_id
52
56
  if self.role_arn:
53
57
  data["role_arn"] = self.role_arn
54
58
  if self.external_id:
@@ -40,6 +40,7 @@ class SetupResult:
40
40
  role_arn: str
41
41
  external_id: str
42
42
  stack_name: str
43
+ stack_id: str
43
44
  kms_key_arn: str
44
45
 
45
46
 
@@ -216,12 +217,142 @@ def _is_newer_status(new_status: str, old_status: str) -> bool:
216
217
  return True
217
218
 
218
219
 
220
+ def get_stack_console_url(stack_id: str, region: str) -> str:
221
+ """Get AWS Console URL for a CloudFormation stack."""
222
+ # URL-encode the stack ID (it contains special characters)
223
+ from urllib.parse import quote
224
+ encoded_stack_id = quote(stack_id, safe="")
225
+ return f"https://{region}.console.aws.amazon.com/cloudformation/home?region={region}#/stacks/stackinfo?stackId={encoded_stack_id}"
226
+
227
+
228
+ def start_stack_deletion(stack_name: str, region: str) -> "CloudFormationClient":
229
+ """Start CloudFormation stack deletion. Returns cfn_client for polling."""
230
+ cfn = cast("CloudFormationClient", boto3.client("cloudformation", region_name=region)) # pyright: ignore[reportUnknownMemberType]
231
+
232
+ try:
233
+ cfn.delete_stack(StackName=stack_name) # pyright: ignore[reportUnknownMemberType]
234
+ except ClientError as e:
235
+ raise Exception(f"Failed to delete stack: {e}") from e
236
+
237
+ return cfn
238
+
239
+
240
+ def poll_stack_deletion(
241
+ stack_name: str,
242
+ cfn: "CloudFormationClient",
243
+ poll_interval: float = 2.0,
244
+ max_attempts: int = 300,
245
+ ) -> Generator[StackProgress, None, None]:
246
+ """Poll stack deletion progress and yield updates."""
247
+ resources: dict[str, ResourceStatus] = {}
248
+
249
+ for _ in range(max_attempts):
250
+ # Get current stack status
251
+ try:
252
+ stacks_response = cfn.describe_stacks(StackName=stack_name)
253
+ except ClientError as e:
254
+ # Stack not found means deletion complete
255
+ if "does not exist" in str(e):
256
+ yield StackProgress(
257
+ resources=resources,
258
+ stack_status="DELETE_COMPLETE",
259
+ is_complete=True,
260
+ is_failed=False,
261
+ )
262
+ return
263
+ raise
264
+
265
+ stack = stacks_response["Stacks"][0]
266
+ stack_status = stack.get("StackStatus", "UNKNOWN")
267
+
268
+ # Get resource events (only delete-related)
269
+ try:
270
+ events_response = cfn.describe_stack_events(StackName=stack_name)
271
+ for event in events_response["StackEvents"]:
272
+ logical_id = event.get("LogicalResourceId", "")
273
+ resource_type = event.get("ResourceType", "")
274
+ status = event.get("ResourceStatus", "")
275
+ reason = event.get("ResourceStatusReason")
276
+
277
+ # Skip the stack itself
278
+ if resource_type == "AWS::CloudFormation::Stack":
279
+ continue
280
+
281
+ # Only track delete-related statuses
282
+ if "DELETE" not in status:
283
+ continue
284
+
285
+ # Update if this is a newer status for this resource
286
+ if logical_id not in resources or _is_newer_delete_status(status, resources[logical_id].status):
287
+ resources[logical_id] = ResourceStatus(
288
+ logical_id=logical_id,
289
+ resource_type=resource_type,
290
+ status=status,
291
+ reason=reason,
292
+ )
293
+ except ClientError:
294
+ pass # Stack events may not be available
295
+
296
+ # Check for completion
297
+ is_complete = stack_status == "DELETE_COMPLETE"
298
+ is_failed = stack_status == "DELETE_FAILED"
299
+
300
+ failure_reason: str | None = None
301
+ if is_failed:
302
+ failed_resources = [r for r in resources.values() if "FAILED" in r.status]
303
+ if failed_resources:
304
+ reasons = [r.reason for r in failed_resources if r.reason]
305
+ failure_reason = "; ".join(reasons[:3]) if reasons else "Unknown error"
306
+
307
+ yield StackProgress(
308
+ resources=resources,
309
+ stack_status=stack_status,
310
+ is_complete=is_complete,
311
+ is_failed=is_failed,
312
+ failure_reason=failure_reason,
313
+ )
314
+
315
+ if is_complete or is_failed:
316
+ return
317
+
318
+ time.sleep(poll_interval)
319
+
320
+ # Timeout
321
+ yield StackProgress(
322
+ resources=resources,
323
+ stack_status="TIMEOUT",
324
+ is_complete=False,
325
+ is_failed=True,
326
+ failure_reason="Stack deletion timed out",
327
+ )
328
+
329
+
330
+ def _is_newer_delete_status(new_status: str, old_status: str) -> bool:
331
+ """Check if new_status is more recent than old_status for deletion."""
332
+ status_order = [
333
+ "CREATE_COMPLETE",
334
+ "DELETE_IN_PROGRESS",
335
+ "DELETE_COMPLETE",
336
+ "DELETE_FAILED",
337
+ "DELETE_SKIPPED",
338
+ ]
339
+
340
+ try:
341
+ new_idx = status_order.index(new_status)
342
+ old_idx = status_order.index(old_status)
343
+ return new_idx > old_idx
344
+ except ValueError:
345
+ return True
346
+
347
+
219
348
  def get_stack_outputs(stack_name: str, region: str) -> SetupResult:
220
349
  """Get outputs from a completed stack."""
221
350
  cfn = cast("CloudFormationClient", boto3.client("cloudformation", region_name=region)) # pyright: ignore[reportUnknownMemberType]
222
351
 
223
352
  response = cfn.describe_stacks(StackName=stack_name)
224
- stack_outputs = response["Stacks"][0].get("Outputs", [])
353
+ stack = response["Stacks"][0]
354
+ stack_id = stack.get("StackId", "")
355
+ stack_outputs = stack.get("Outputs", [])
225
356
  outputs: dict[str, str] = {
226
357
  o["OutputKey"]: o["OutputValue"]
227
358
  for o in stack_outputs
@@ -234,6 +365,7 @@ def get_stack_outputs(stack_name: str, region: str) -> SetupResult:
234
365
  role_arn=outputs["RoleArn"],
235
366
  external_id=outputs["ExternalId"],
236
367
  stack_name=stack_name,
368
+ stack_id=stack_id,
237
369
  kms_key_arn=outputs["KmsKeyArn"],
238
370
  )
239
371
 
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: exchange-keyshare
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: CLI for market makers to securely share exchange API credentials
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: boto3>=1.34.0
7
+ Requires-Dist: botocore[crt]>=1.34.0
7
8
  Requires-Dist: click>=8.1.0
8
9
  Requires-Dist: pyyaml>=6.0
9
10
  Requires-Dist: questionary>=2.0.0
@@ -0,0 +1,17 @@
1
+ exchange_keyshare/__init__.py,sha256=r2L4CpACTGPQTgruORVSNLxpD1vQD6zbcigQqDzzpt8,111
2
+ exchange_keyshare/cfn.py,sha256=Jr8QjeRHS9AP44vx14N_023we0iWjIHAz5Np9dnATe4,364
3
+ exchange_keyshare/cli.py,sha256=jYDKMvVtvm35Q5L1CNdNfuq_XF_6oq868nAK3Jg11no,987
4
+ exchange_keyshare/config.py,sha256=Db0hKKfBHISC-tnHp9blTSE2_lIuC1QdDxMuL3rATxs,2816
5
+ exchange_keyshare/keys.py,sha256=Lyl7vgJ4Bo0XA4iBKJyRWGd1ZpHPmrAuY7T0-sUWpOM,3278
6
+ exchange_keyshare/schema.py,sha256=BRv22tV3TifkoADVlMPGPNbhc9DKHtPq56s1EDcy70w,3818
7
+ exchange_keyshare/setup.py,sha256=fbWWl3meMWQ1RLAGcGxrBlDhxu_Np3Vybcu1GtytzfU,12929
8
+ exchange_keyshare/commands/__init__.py,sha256=gQ6tnU0Rvm0-ESWFUBU-KDl5dpNOpUTG509hXOQQjwY,27
9
+ exchange_keyshare/commands/config.py,sha256=1MXPIMWiSy1KerWdr9r8y6P7aA2YQRwk4wWeF7duSR0,1065
10
+ exchange_keyshare/commands/keys.py,sha256=NX_BDl2-iWoLso5ep9kQNCjx5r7SszUg2MY62qJ-eHc,14610
11
+ exchange_keyshare/commands/setup.py,sha256=VXvCXkTwlEIYOyflh8IGpBJrf8QiTeGIq2Mb1RghFic,5765
12
+ exchange_keyshare/commands/teardown.py,sha256=hHS9k6FNpoF9Dk4zrIHlKJzTmDVLy89fg39ACs_jPU4,7514
13
+ exchange_keyshare/templates/stack.yaml,sha256=KdNEeTFHm24pxQbSKzWxi2VyyjkKBYB9mYlBvSA6gfQ,6456
14
+ exchange_keyshare-0.1.2.dist-info/METADATA,sha256=irCUjn7m1APvpsgZw6SqO4ULr_Bh2bB2W6m6y9K3OLQ,342
15
+ exchange_keyshare-0.1.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
16
+ exchange_keyshare-0.1.2.dist-info/entry_points.txt,sha256=4SeV5jhxW3C6plg8WefZdLhPBRqmYErdKfnGTUEWmmo,65
17
+ exchange_keyshare-0.1.2.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- exchange_keyshare/__init__.py,sha256=r2L4CpACTGPQTgruORVSNLxpD1vQD6zbcigQqDzzpt8,111
2
- exchange_keyshare/cfn.py,sha256=Jr8QjeRHS9AP44vx14N_023we0iWjIHAz5Np9dnATe4,364
3
- exchange_keyshare/cli.py,sha256=ogQBvZmrbR18QPQ4RJW9nERac3JqO_DPnk4RgyVjORU,825
4
- exchange_keyshare/config.py,sha256=QD2apmjj99J9j5svOVS209726pS7uK5rcaxTAgYYBj4,2668
5
- exchange_keyshare/keys.py,sha256=Lyl7vgJ4Bo0XA4iBKJyRWGd1ZpHPmrAuY7T0-sUWpOM,3278
6
- exchange_keyshare/schema.py,sha256=BRv22tV3TifkoADVlMPGPNbhc9DKHtPq56s1EDcy70w,3818
7
- exchange_keyshare/setup.py,sha256=n0Rn-gl8tFautc34HnA5lm-R0iCx7SAQAc7LTL9Zyn8,8235
8
- exchange_keyshare/commands/__init__.py,sha256=gQ6tnU0Rvm0-ESWFUBU-KDl5dpNOpUTG509hXOQQjwY,27
9
- exchange_keyshare/commands/keys.py,sha256=NX_BDl2-iWoLso5ep9kQNCjx5r7SszUg2MY62qJ-eHc,14610
10
- exchange_keyshare/commands/setup.py,sha256=GTfFP4H6JJtIah47ruSlETLy9mJm6XvZbUpEN-OUMo0,5727
11
- exchange_keyshare/templates/stack.yaml,sha256=KdNEeTFHm24pxQbSKzWxi2VyyjkKBYB9mYlBvSA6gfQ,6456
12
- exchange_keyshare-0.1.0.dist-info/METADATA,sha256=B4dXgTPHXQgLBWuUJN-h7mzUedSK72-RlT4pKoNAL-c,305
13
- exchange_keyshare-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
- exchange_keyshare-0.1.0.dist-info/entry_points.txt,sha256=4SeV5jhxW3C6plg8WefZdLhPBRqmYErdKfnGTUEWmmo,65
15
- exchange_keyshare-0.1.0.dist-info/RECORD,,