pltr-cli 0.5.0__py3-none-any.whl → 0.5.1__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.
- pltr/__init__.py +1 -1
- pltr/cli.py +14 -0
- pltr/commands/connectivity.py +432 -0
- pltr/commands/dataset.py +268 -0
- pltr/commands/project.py +440 -0
- pltr/commands/resource.py +499 -0
- pltr/commands/resource_role.py +454 -0
- pltr/commands/space.py +662 -0
- pltr/services/connectivity.py +305 -0
- pltr/services/dataset.py +243 -8
- pltr/services/project.py +232 -0
- pltr/services/resource.py +289 -0
- pltr/services/resource_role.py +321 -0
- pltr/services/space.py +354 -0
- pltr/utils/formatting.py +108 -1
- {pltr_cli-0.5.0.dist-info → pltr_cli-0.5.1.dist-info}/METADATA +101 -2
- {pltr_cli-0.5.0.dist-info → pltr_cli-0.5.1.dist-info}/RECORD +20 -10
- {pltr_cli-0.5.0.dist-info → pltr_cli-0.5.1.dist-info}/WHEEL +0 -0
- {pltr_cli-0.5.0.dist-info → pltr_cli-0.5.1.dist-info}/entry_points.txt +0 -0
- {pltr_cli-0.5.0.dist-info → pltr_cli-0.5.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resource Role management commands for Foundry filesystem.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from ..services.resource_role import ResourceRoleService
|
|
11
|
+
from ..utils.formatting import OutputFormatter
|
|
12
|
+
from ..utils.progress import SpinnerProgressTracker
|
|
13
|
+
from ..auth.base import ProfileNotFoundError, MissingCredentialsError
|
|
14
|
+
from ..utils.completion import (
|
|
15
|
+
complete_rid,
|
|
16
|
+
complete_profile,
|
|
17
|
+
complete_output_format,
|
|
18
|
+
cache_rid,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
app = typer.Typer()
|
|
22
|
+
console = Console()
|
|
23
|
+
formatter = OutputFormatter(console)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("grant")
|
|
27
|
+
def grant_role(
|
|
28
|
+
resource_rid: str = typer.Argument(
|
|
29
|
+
..., help="Resource Identifier", autocompletion=complete_rid
|
|
30
|
+
),
|
|
31
|
+
principal_id: str = typer.Option(
|
|
32
|
+
..., "--principal-id", "-p", help="Principal (user/group) identifier"
|
|
33
|
+
),
|
|
34
|
+
principal_type: str = typer.Option(
|
|
35
|
+
...,
|
|
36
|
+
"--principal-type",
|
|
37
|
+
"-t",
|
|
38
|
+
help="Principal type (User or Group)",
|
|
39
|
+
),
|
|
40
|
+
role_name: str = typer.Option(..., "--role", "-r", help="Role name to grant"),
|
|
41
|
+
profile: Optional[str] = typer.Option(
|
|
42
|
+
None, "--profile", help="Profile name", autocompletion=complete_profile
|
|
43
|
+
),
|
|
44
|
+
format: str = typer.Option(
|
|
45
|
+
"table",
|
|
46
|
+
"--format",
|
|
47
|
+
"-f",
|
|
48
|
+
help="Output format (table, json, csv)",
|
|
49
|
+
autocompletion=complete_output_format,
|
|
50
|
+
),
|
|
51
|
+
):
|
|
52
|
+
"""Grant a role to a principal on a resource."""
|
|
53
|
+
try:
|
|
54
|
+
service = ResourceRoleService(profile=profile)
|
|
55
|
+
|
|
56
|
+
with SpinnerProgressTracker().track_spinner(
|
|
57
|
+
f"Granting role '{role_name}' to {principal_type} '{principal_id}' on {resource_rid}..."
|
|
58
|
+
):
|
|
59
|
+
role_grant = service.grant_role(
|
|
60
|
+
resource_rid=resource_rid,
|
|
61
|
+
principal_id=principal_id,
|
|
62
|
+
principal_type=principal_type.title(),
|
|
63
|
+
role_name=role_name,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
formatter.print_success(
|
|
67
|
+
f"Successfully granted role '{role_name}' to {principal_type} '{principal_id}'"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Format output
|
|
71
|
+
if format == "json":
|
|
72
|
+
formatter.format_dict(role_grant)
|
|
73
|
+
elif format == "csv":
|
|
74
|
+
formatter.format_list([role_grant])
|
|
75
|
+
else:
|
|
76
|
+
_format_role_grant_table(role_grant)
|
|
77
|
+
|
|
78
|
+
except (ProfileNotFoundError, MissingCredentialsError) as e:
|
|
79
|
+
formatter.print_error(f"Authentication error: {e}")
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
formatter.print_error(f"Failed to grant role: {e}")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("revoke")
|
|
87
|
+
def revoke_role(
|
|
88
|
+
resource_rid: str = typer.Argument(
|
|
89
|
+
..., help="Resource Identifier", autocompletion=complete_rid
|
|
90
|
+
),
|
|
91
|
+
principal_id: str = typer.Option(
|
|
92
|
+
..., "--principal-id", "-p", help="Principal (user/group) identifier"
|
|
93
|
+
),
|
|
94
|
+
principal_type: str = typer.Option(
|
|
95
|
+
...,
|
|
96
|
+
"--principal-type",
|
|
97
|
+
"-t",
|
|
98
|
+
help="Principal type (User or Group)",
|
|
99
|
+
),
|
|
100
|
+
role_name: str = typer.Option(..., "--role", "-r", help="Role name to revoke"),
|
|
101
|
+
profile: Optional[str] = typer.Option(
|
|
102
|
+
None, "--profile", help="Profile name", autocompletion=complete_profile
|
|
103
|
+
),
|
|
104
|
+
confirm: bool = typer.Option(False, "--confirm", help="Skip confirmation prompt"),
|
|
105
|
+
):
|
|
106
|
+
"""Revoke a role from a principal on a resource."""
|
|
107
|
+
try:
|
|
108
|
+
if not confirm:
|
|
109
|
+
confirm_revoke = typer.confirm(
|
|
110
|
+
f"Are you sure you want to revoke role '{role_name}' from {principal_type} '{principal_id}' on resource {resource_rid}?"
|
|
111
|
+
)
|
|
112
|
+
if not confirm_revoke:
|
|
113
|
+
formatter.print_info("Role revocation cancelled.")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
service = ResourceRoleService(profile=profile)
|
|
117
|
+
|
|
118
|
+
with SpinnerProgressTracker().track_spinner(
|
|
119
|
+
f"Revoking role '{role_name}' from {principal_type} '{principal_id}' on {resource_rid}..."
|
|
120
|
+
):
|
|
121
|
+
service.revoke_role(
|
|
122
|
+
resource_rid=resource_rid,
|
|
123
|
+
principal_id=principal_id,
|
|
124
|
+
principal_type=principal_type.title(),
|
|
125
|
+
role_name=role_name,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
formatter.print_success(
|
|
129
|
+
f"Successfully revoked role '{role_name}' from {principal_type} '{principal_id}'"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
except (ProfileNotFoundError, MissingCredentialsError) as e:
|
|
133
|
+
formatter.print_error(f"Authentication error: {e}")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
formatter.print_error(f"Failed to revoke role: {e}")
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command("list")
|
|
141
|
+
def list_resource_roles(
|
|
142
|
+
resource_rid: str = typer.Argument(
|
|
143
|
+
..., help="Resource Identifier", autocompletion=complete_rid
|
|
144
|
+
),
|
|
145
|
+
principal_type: Optional[str] = typer.Option(
|
|
146
|
+
None,
|
|
147
|
+
"--principal-type",
|
|
148
|
+
"-t",
|
|
149
|
+
help="Filter by principal type (User or Group)",
|
|
150
|
+
),
|
|
151
|
+
profile: Optional[str] = typer.Option(
|
|
152
|
+
None, "--profile", help="Profile name", autocompletion=complete_profile
|
|
153
|
+
),
|
|
154
|
+
format: str = typer.Option(
|
|
155
|
+
"table",
|
|
156
|
+
"--format",
|
|
157
|
+
"-f",
|
|
158
|
+
help="Output format (table, json, csv)",
|
|
159
|
+
autocompletion=complete_output_format,
|
|
160
|
+
),
|
|
161
|
+
output: Optional[str] = typer.Option(
|
|
162
|
+
None, "--output", "-o", help="Output file path"
|
|
163
|
+
),
|
|
164
|
+
page_size: Optional[int] = typer.Option(
|
|
165
|
+
None, "--page-size", help="Number of items per page"
|
|
166
|
+
),
|
|
167
|
+
):
|
|
168
|
+
"""List all roles granted on a resource."""
|
|
169
|
+
try:
|
|
170
|
+
# Cache the RID for future completions
|
|
171
|
+
cache_rid(resource_rid)
|
|
172
|
+
|
|
173
|
+
service = ResourceRoleService(profile=profile)
|
|
174
|
+
|
|
175
|
+
filter_desc = f" for {principal_type}s" if principal_type else ""
|
|
176
|
+
with SpinnerProgressTracker().track_spinner(
|
|
177
|
+
f"Listing roles on {resource_rid}{filter_desc}..."
|
|
178
|
+
):
|
|
179
|
+
role_grants = service.list_resource_roles(
|
|
180
|
+
resource_rid=resource_rid,
|
|
181
|
+
principal_type=principal_type.title() if principal_type else None,
|
|
182
|
+
page_size=page_size,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if not role_grants:
|
|
186
|
+
formatter.print_info(f"No roles found on resource {resource_rid}.")
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
# Format output
|
|
190
|
+
if format == "json":
|
|
191
|
+
if output:
|
|
192
|
+
formatter.save_to_file(role_grants, output, "json")
|
|
193
|
+
else:
|
|
194
|
+
formatter.format_list(role_grants)
|
|
195
|
+
elif format == "csv":
|
|
196
|
+
if output:
|
|
197
|
+
formatter.save_to_file(role_grants, output, "csv")
|
|
198
|
+
else:
|
|
199
|
+
formatter.format_list(role_grants)
|
|
200
|
+
else:
|
|
201
|
+
_format_role_grants_table(role_grants)
|
|
202
|
+
|
|
203
|
+
if output:
|
|
204
|
+
formatter.print_success(f"Role grants saved to {output}")
|
|
205
|
+
|
|
206
|
+
except (ProfileNotFoundError, MissingCredentialsError) as e:
|
|
207
|
+
formatter.print_error(f"Authentication error: {e}")
|
|
208
|
+
raise typer.Exit(1)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
formatter.print_error(f"Failed to list resource roles: {e}")
|
|
211
|
+
raise typer.Exit(1)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command("get-principal-roles")
|
|
215
|
+
def get_principal_roles(
|
|
216
|
+
principal_id: str = typer.Option(
|
|
217
|
+
..., "--principal-id", "-p", help="Principal (user/group) identifier"
|
|
218
|
+
),
|
|
219
|
+
principal_type: str = typer.Option(
|
|
220
|
+
...,
|
|
221
|
+
"--principal-type",
|
|
222
|
+
"-t",
|
|
223
|
+
help="Principal type (User or Group)",
|
|
224
|
+
),
|
|
225
|
+
resource_rid: Optional[str] = typer.Option(
|
|
226
|
+
None,
|
|
227
|
+
"--resource-rid",
|
|
228
|
+
"-r",
|
|
229
|
+
help="Filter by resource RID",
|
|
230
|
+
autocompletion=complete_rid,
|
|
231
|
+
),
|
|
232
|
+
profile: Optional[str] = typer.Option(
|
|
233
|
+
None, "--profile", help="Profile name", autocompletion=complete_profile
|
|
234
|
+
),
|
|
235
|
+
format: str = typer.Option(
|
|
236
|
+
"table",
|
|
237
|
+
"--format",
|
|
238
|
+
"-f",
|
|
239
|
+
help="Output format (table, json, csv)",
|
|
240
|
+
autocompletion=complete_output_format,
|
|
241
|
+
),
|
|
242
|
+
output: Optional[str] = typer.Option(
|
|
243
|
+
None, "--output", "-o", help="Output file path"
|
|
244
|
+
),
|
|
245
|
+
page_size: Optional[int] = typer.Option(
|
|
246
|
+
None, "--page-size", help="Number of items per page"
|
|
247
|
+
),
|
|
248
|
+
):
|
|
249
|
+
"""Get all roles granted to a principal, optionally filtered by resource."""
|
|
250
|
+
try:
|
|
251
|
+
service = ResourceRoleService(profile=profile)
|
|
252
|
+
|
|
253
|
+
filter_desc = f" on resource {resource_rid}" if resource_rid else ""
|
|
254
|
+
with SpinnerProgressTracker().track_spinner(
|
|
255
|
+
f"Getting roles for {principal_type} '{principal_id}'{filter_desc}..."
|
|
256
|
+
):
|
|
257
|
+
role_grants = service.get_principal_roles(
|
|
258
|
+
principal_id=principal_id,
|
|
259
|
+
principal_type=principal_type.title(),
|
|
260
|
+
resource_rid=resource_rid,
|
|
261
|
+
page_size=page_size,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if not role_grants:
|
|
265
|
+
formatter.print_info(
|
|
266
|
+
f"No roles found for {principal_type} '{principal_id}'."
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
# Format output
|
|
271
|
+
if format == "json":
|
|
272
|
+
if output:
|
|
273
|
+
formatter.save_to_file(role_grants, output, "json")
|
|
274
|
+
else:
|
|
275
|
+
formatter.format_list(role_grants)
|
|
276
|
+
elif format == "csv":
|
|
277
|
+
if output:
|
|
278
|
+
formatter.save_to_file(role_grants, output, "csv")
|
|
279
|
+
else:
|
|
280
|
+
formatter.format_list(role_grants)
|
|
281
|
+
else:
|
|
282
|
+
_format_role_grants_table(role_grants)
|
|
283
|
+
|
|
284
|
+
if output:
|
|
285
|
+
formatter.print_success(f"Role grants saved to {output}")
|
|
286
|
+
|
|
287
|
+
except (ProfileNotFoundError, MissingCredentialsError) as e:
|
|
288
|
+
formatter.print_error(f"Authentication error: {e}")
|
|
289
|
+
raise typer.Exit(1)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
formatter.print_error(f"Failed to get principal roles: {e}")
|
|
292
|
+
raise typer.Exit(1)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.command("get-available-roles")
|
|
296
|
+
def get_available_roles(
|
|
297
|
+
resource_rid: str = typer.Argument(
|
|
298
|
+
..., help="Resource Identifier", autocompletion=complete_rid
|
|
299
|
+
),
|
|
300
|
+
profile: Optional[str] = typer.Option(
|
|
301
|
+
None, "--profile", help="Profile name", autocompletion=complete_profile
|
|
302
|
+
),
|
|
303
|
+
format: str = typer.Option(
|
|
304
|
+
"table",
|
|
305
|
+
"--format",
|
|
306
|
+
"-f",
|
|
307
|
+
help="Output format (table, json, csv)",
|
|
308
|
+
autocompletion=complete_output_format,
|
|
309
|
+
),
|
|
310
|
+
output: Optional[str] = typer.Option(
|
|
311
|
+
None, "--output", "-o", help="Output file path"
|
|
312
|
+
),
|
|
313
|
+
):
|
|
314
|
+
"""Get all available roles for a resource type."""
|
|
315
|
+
try:
|
|
316
|
+
service = ResourceRoleService(profile=profile)
|
|
317
|
+
|
|
318
|
+
with SpinnerProgressTracker().track_spinner(
|
|
319
|
+
f"Getting available roles for {resource_rid}..."
|
|
320
|
+
):
|
|
321
|
+
roles = service.get_available_roles(resource_rid)
|
|
322
|
+
|
|
323
|
+
if not roles:
|
|
324
|
+
formatter.print_info(
|
|
325
|
+
f"No available roles found for resource {resource_rid}."
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
# Format output
|
|
330
|
+
if format == "json":
|
|
331
|
+
if output:
|
|
332
|
+
formatter.save_to_file(roles, output, "json")
|
|
333
|
+
else:
|
|
334
|
+
formatter.format_list(roles)
|
|
335
|
+
elif format == "csv":
|
|
336
|
+
if output:
|
|
337
|
+
formatter.save_to_file(roles, output, "csv")
|
|
338
|
+
else:
|
|
339
|
+
formatter.format_list(roles)
|
|
340
|
+
else:
|
|
341
|
+
_format_available_roles_table(roles)
|
|
342
|
+
|
|
343
|
+
if output:
|
|
344
|
+
formatter.print_success(f"Available roles saved to {output}")
|
|
345
|
+
|
|
346
|
+
except (ProfileNotFoundError, MissingCredentialsError) as e:
|
|
347
|
+
formatter.print_error(f"Authentication error: {e}")
|
|
348
|
+
raise typer.Exit(1)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
formatter.print_error(f"Failed to get available roles: {e}")
|
|
351
|
+
raise typer.Exit(1)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _format_role_grant_table(role_grant: dict):
|
|
355
|
+
"""Format role grant information as a table."""
|
|
356
|
+
table = Table(
|
|
357
|
+
title="Role Grant Information", show_header=True, header_style="bold cyan"
|
|
358
|
+
)
|
|
359
|
+
table.add_column("Property", style="cyan")
|
|
360
|
+
table.add_column("Value")
|
|
361
|
+
|
|
362
|
+
table.add_row("Resource RID", role_grant.get("resource_rid", "N/A"))
|
|
363
|
+
table.add_row("Principal ID", role_grant.get("principal_id", "N/A"))
|
|
364
|
+
table.add_row("Principal Type", role_grant.get("principal_type", "N/A"))
|
|
365
|
+
table.add_row("Role Name", role_grant.get("role_name", "N/A"))
|
|
366
|
+
table.add_row("Granted By", role_grant.get("granted_by", "N/A"))
|
|
367
|
+
table.add_row("Granted Time", role_grant.get("granted_time", "N/A"))
|
|
368
|
+
table.add_row("Expires At", role_grant.get("expires_at", "N/A"))
|
|
369
|
+
|
|
370
|
+
console.print(table)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _format_role_grants_table(role_grants: List[dict]):
|
|
374
|
+
"""Format multiple role grants as a table."""
|
|
375
|
+
table = Table(title="Role Grants", show_header=True, header_style="bold cyan")
|
|
376
|
+
table.add_column("Principal Type")
|
|
377
|
+
table.add_column("Principal ID")
|
|
378
|
+
table.add_column("Role Name")
|
|
379
|
+
table.add_column("Granted By")
|
|
380
|
+
table.add_column("Granted Time")
|
|
381
|
+
|
|
382
|
+
for grant in role_grants:
|
|
383
|
+
table.add_row(
|
|
384
|
+
grant.get("principal_type", "N/A"),
|
|
385
|
+
grant.get("principal_id", "N/A"),
|
|
386
|
+
grant.get("role_name", "N/A"),
|
|
387
|
+
grant.get("granted_by", "N/A"),
|
|
388
|
+
grant.get("granted_time", "N/A"),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
console.print(table)
|
|
392
|
+
console.print(f"\nTotal: {len(role_grants)} role grants")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _format_available_roles_table(roles: List[dict]):
|
|
396
|
+
"""Format available roles as a table."""
|
|
397
|
+
table = Table(title="Available Roles", show_header=True, header_style="bold cyan")
|
|
398
|
+
table.add_column("Name")
|
|
399
|
+
table.add_column("Display Name")
|
|
400
|
+
table.add_column("Description")
|
|
401
|
+
table.add_column("Owner-like")
|
|
402
|
+
|
|
403
|
+
for role in roles:
|
|
404
|
+
table.add_row(
|
|
405
|
+
role.get("name", "N/A"),
|
|
406
|
+
role.get("display_name", "N/A"),
|
|
407
|
+
role.get("description", "N/A"),
|
|
408
|
+
"Yes" if role.get("is_owner_like", False) else "No",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
console.print(table)
|
|
412
|
+
console.print(f"\nTotal: {len(roles)} available roles")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@app.callback()
|
|
416
|
+
def main():
|
|
417
|
+
"""
|
|
418
|
+
Resource Role operations using foundry-platform-sdk.
|
|
419
|
+
|
|
420
|
+
Manage permissions and access control for resources in the Foundry filesystem.
|
|
421
|
+
Grant and revoke roles for users and groups on specific resources.
|
|
422
|
+
|
|
423
|
+
Examples:
|
|
424
|
+
# Grant a role to a user
|
|
425
|
+
pltr resource-role grant ri.compass.main.dataset.xyz123 \\
|
|
426
|
+
--principal-id user123 --principal-type User --role viewer
|
|
427
|
+
|
|
428
|
+
# Grant a role to a group
|
|
429
|
+
pltr resource-role grant ri.compass.main.project.abc456 \\
|
|
430
|
+
--principal-id group789 --principal-type Group --role editor
|
|
431
|
+
|
|
432
|
+
# List all roles on a resource
|
|
433
|
+
pltr resource-role list ri.compass.main.dataset.xyz123
|
|
434
|
+
|
|
435
|
+
# List only user roles on a resource
|
|
436
|
+
pltr resource-role list ri.compass.main.dataset.xyz123 --principal-type User
|
|
437
|
+
|
|
438
|
+
# Get all roles for a specific user
|
|
439
|
+
pltr resource-role get-principal-roles \\
|
|
440
|
+
--principal-id user123 --principal-type User
|
|
441
|
+
|
|
442
|
+
# Get roles for a user on a specific resource
|
|
443
|
+
pltr resource-role get-principal-roles \\
|
|
444
|
+
--principal-id user123 --principal-type User \\
|
|
445
|
+
--resource-rid ri.compass.main.dataset.xyz123
|
|
446
|
+
|
|
447
|
+
# See what roles are available for a resource
|
|
448
|
+
pltr resource-role get-available-roles ri.compass.main.dataset.xyz123
|
|
449
|
+
|
|
450
|
+
# Revoke a role from a user
|
|
451
|
+
pltr resource-role revoke ri.compass.main.dataset.xyz123 \\
|
|
452
|
+
--principal-id user123 --principal-type User --role viewer
|
|
453
|
+
"""
|
|
454
|
+
pass
|