systemlink-cli 1.3.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.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/policy_utils.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Utility functions for auth policy management.
|
|
2
|
+
|
|
3
|
+
Provides helper functions for policy and policy template operations,
|
|
4
|
+
including formatting, validation, and API interaction helpers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .utils import get_base_url, make_api_request
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _fetch_policy_details(policy_id: str, handle_errors: bool = True) -> Optional[Dict[str, Any]]:
|
|
15
|
+
"""Fetch policy details from the Auth service.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
policy_id: The policy ID to fetch
|
|
19
|
+
handle_errors: Whether to raise exceptions on API errors
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Policy details dictionary, or None if not found/no permission
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
url = f"{get_base_url()}/niauth/v1/policies/{policy_id}"
|
|
26
|
+
resp = make_api_request("GET", url, payload=None, handle_errors=handle_errors)
|
|
27
|
+
return resp.json()
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fetch_template_details(
|
|
33
|
+
template_id: str, handle_errors: bool = True
|
|
34
|
+
) -> Optional[Dict[str, Any]]:
|
|
35
|
+
"""Fetch policy template details from the Auth service.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
template_id: The policy template ID to fetch
|
|
39
|
+
handle_errors: Whether to raise exceptions on API errors
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Policy template details dictionary, or None if not found/no permission
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
url = f"{get_base_url()}/niauth/v1/policy-templates/{template_id}"
|
|
46
|
+
resp = make_api_request("GET", url, payload=None, handle_errors=handle_errors)
|
|
47
|
+
return resp.json()
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_statements_for_display(statements: List[Dict[str, Any]]) -> str:
|
|
53
|
+
"""Format statements in a readable way for display.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
statements: List of statement dictionaries from API
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Formatted string representation
|
|
60
|
+
"""
|
|
61
|
+
if not statements:
|
|
62
|
+
return "No statements"
|
|
63
|
+
|
|
64
|
+
lines: List[str] = []
|
|
65
|
+
for i, statement in enumerate(statements, 1):
|
|
66
|
+
lines.append(f"\nStatement {i}:")
|
|
67
|
+
|
|
68
|
+
workspace = statement.get("workspace", "N/A")
|
|
69
|
+
lines.append(f" Workspace: {workspace}")
|
|
70
|
+
|
|
71
|
+
# Format actions
|
|
72
|
+
actions = statement.get("actions", [])
|
|
73
|
+
if actions:
|
|
74
|
+
lines.append(f" Actions ({len(actions)}):")
|
|
75
|
+
for action in actions:
|
|
76
|
+
lines.append(f" • {action}")
|
|
77
|
+
|
|
78
|
+
# Format resources
|
|
79
|
+
resources = statement.get("resource", [])
|
|
80
|
+
if resources:
|
|
81
|
+
lines.append(f" Resources ({len(resources)}):")
|
|
82
|
+
for resource in resources:
|
|
83
|
+
lines.append(f" • {resource}")
|
|
84
|
+
|
|
85
|
+
# Format description if present
|
|
86
|
+
description = statement.get("description")
|
|
87
|
+
if description:
|
|
88
|
+
lines.append(f" Description: {description}")
|
|
89
|
+
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _validate_statements(statements: List[Dict[str, Any]]) -> Tuple[bool, Optional[str]]:
|
|
94
|
+
"""Validate statement structure.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
statements: List of statement dictionaries to validate
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Tuple of (is_valid, error_message)
|
|
101
|
+
"""
|
|
102
|
+
if not statements:
|
|
103
|
+
return False, "At least one statement is required"
|
|
104
|
+
|
|
105
|
+
for i, stmt in enumerate(statements):
|
|
106
|
+
# Validate stmt is a dictionary
|
|
107
|
+
is_dict: bool = isinstance(stmt, dict)
|
|
108
|
+
if not is_dict:
|
|
109
|
+
return False, f"Statement {i + 1} is not a dictionary"
|
|
110
|
+
|
|
111
|
+
# Check required fields exist and have correct types
|
|
112
|
+
actions: Any = stmt.get("actions")
|
|
113
|
+
resources: Any = stmt.get("resource")
|
|
114
|
+
workspace: Any = stmt.get("workspace")
|
|
115
|
+
|
|
116
|
+
# Validate actions field
|
|
117
|
+
is_actions_list: bool = isinstance(actions, list)
|
|
118
|
+
if not is_actions_list:
|
|
119
|
+
return False, f"Statement {i + 1}: 'actions' must be a list"
|
|
120
|
+
if not actions: # Empty list check
|
|
121
|
+
return False, f"Statement {i + 1}: 'actions' must not be empty"
|
|
122
|
+
|
|
123
|
+
# Validate resources field
|
|
124
|
+
is_resources_list: bool = isinstance(resources, list)
|
|
125
|
+
if not is_resources_list:
|
|
126
|
+
return False, f"Statement {i + 1}: 'resource' must be a list"
|
|
127
|
+
if not resources: # Empty list check
|
|
128
|
+
return False, f"Statement {i + 1}: 'resource' must not be empty"
|
|
129
|
+
|
|
130
|
+
# Validate workspace field
|
|
131
|
+
is_workspace_str: bool = isinstance(workspace, str)
|
|
132
|
+
if not is_workspace_str:
|
|
133
|
+
return False, f"Statement {i + 1}: 'workspace' must be a string"
|
|
134
|
+
if not workspace: # Empty string check
|
|
135
|
+
return False, f"Statement {i + 1}: 'workspace' must not be empty"
|
|
136
|
+
|
|
137
|
+
return True, None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _format_policy_list_row(policy: Dict[str, Any]) -> List[str]:
|
|
141
|
+
"""Format a policy for table list output.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
policy: Policy dictionary from API
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of formatted column values
|
|
148
|
+
"""
|
|
149
|
+
policy_id = policy.get("id", "N/A")
|
|
150
|
+
name = policy.get("name", "N/A")
|
|
151
|
+
policy_type = policy.get("type", "N/A")
|
|
152
|
+
is_builtin = policy.get("builtIn", False)
|
|
153
|
+
builtin_str = "Yes" if is_builtin else "No"
|
|
154
|
+
|
|
155
|
+
# Count statements (or show "inherited" if template-based)
|
|
156
|
+
template_id = policy.get("templateId")
|
|
157
|
+
if template_id:
|
|
158
|
+
statement_count = "(inherited)"
|
|
159
|
+
else:
|
|
160
|
+
statements = policy.get("statements", [])
|
|
161
|
+
statement_count = str(len(statements))
|
|
162
|
+
|
|
163
|
+
return [policy_id, name, policy_type, builtin_str, statement_count]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _format_template_list_row(template: Dict[str, Any]) -> List[str]:
|
|
167
|
+
"""Format a template for table list output.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
template: Policy template dictionary from API
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
List of formatted column values
|
|
174
|
+
"""
|
|
175
|
+
template_id = template.get("id", "N/A")
|
|
176
|
+
name = template.get("name", "N/A")
|
|
177
|
+
template_type = template.get("type", "N/A")
|
|
178
|
+
is_builtin = template.get("builtIn", False)
|
|
179
|
+
builtin_str = "Yes" if is_builtin else "No"
|
|
180
|
+
|
|
181
|
+
statements = template.get("statements", [])
|
|
182
|
+
statement_count = str(len(statements))
|
|
183
|
+
|
|
184
|
+
return [template_id, name, template_type, builtin_str, statement_count]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_properties_from_cli(properties: tuple) -> Dict[str, str]:
|
|
188
|
+
"""Parse key=value properties from CLI arguments.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
properties: Tuple of "key=value" strings
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Dictionary of parsed properties
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
ValueError: If format is invalid
|
|
198
|
+
"""
|
|
199
|
+
props_dict: Dict[str, str] = {}
|
|
200
|
+
for prop in properties:
|
|
201
|
+
if "=" not in prop:
|
|
202
|
+
raise ValueError(f"Invalid property format: {prop}. Use key=value")
|
|
203
|
+
key, val = prop.split("=", 1)
|
|
204
|
+
props_dict[key.strip()] = val.strip()
|
|
205
|
+
return props_dict
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _load_statements_from_file(file_path: str) -> List[Dict[str, Any]]:
|
|
209
|
+
"""Load statements from a JSON file.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
file_path: Path to JSON file containing statements
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
List of statement dictionaries
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ValueError: If file cannot be read or parsed
|
|
219
|
+
"""
|
|
220
|
+
import json
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
with open(file_path, "r") as f:
|
|
224
|
+
data = json.load(f)
|
|
225
|
+
except FileNotFoundError:
|
|
226
|
+
raise ValueError(f"File not found: {file_path}")
|
|
227
|
+
except json.JSONDecodeError as e:
|
|
228
|
+
raise ValueError(f"Invalid JSON in file: {e}")
|
|
229
|
+
|
|
230
|
+
# Support both direct statements list or wrapped in "statements" key
|
|
231
|
+
if isinstance(data, list):
|
|
232
|
+
return data
|
|
233
|
+
elif isinstance(data, dict) and "statements" in data:
|
|
234
|
+
statements = data["statements"]
|
|
235
|
+
if not isinstance(statements, list):
|
|
236
|
+
raise ValueError('"statements" field must be a list')
|
|
237
|
+
return statements
|
|
238
|
+
else:
|
|
239
|
+
raise ValueError('File must contain a list of statements or a "statements" key')
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _build_policy_payload(
|
|
243
|
+
name: str,
|
|
244
|
+
policy_type: str,
|
|
245
|
+
statements: Optional[List[Dict[str, Any]]] = None,
|
|
246
|
+
template_id: Optional[str] = None,
|
|
247
|
+
workspace: Optional[str] = None,
|
|
248
|
+
properties: Optional[Dict[str, str]] = None,
|
|
249
|
+
) -> Dict[str, Any]:
|
|
250
|
+
"""Build a policy creation/update payload.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
name: Policy name
|
|
254
|
+
policy_type: Policy type (default|internal|custom|role)
|
|
255
|
+
statements: List of statement dictionaries (optional if template_id used)
|
|
256
|
+
template_id: Policy template ID (optional)
|
|
257
|
+
workspace: Workspace ID (required if template_id used)
|
|
258
|
+
properties: Custom properties dictionary
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Policy payload for API request
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
ValueError: If required fields are missing
|
|
265
|
+
"""
|
|
266
|
+
payload: Dict[str, Any] = {
|
|
267
|
+
"name": name,
|
|
268
|
+
"type": policy_type,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if template_id:
|
|
272
|
+
if not workspace:
|
|
273
|
+
raise ValueError("workspace is required when using a template")
|
|
274
|
+
payload["templateId"] = template_id
|
|
275
|
+
payload["workspace"] = workspace
|
|
276
|
+
else:
|
|
277
|
+
if not statements:
|
|
278
|
+
raise ValueError("statements are required if template_id is not used")
|
|
279
|
+
is_valid, error_msg = _validate_statements(statements)
|
|
280
|
+
if not is_valid:
|
|
281
|
+
raise ValueError(error_msg)
|
|
282
|
+
payload["statements"] = statements
|
|
283
|
+
|
|
284
|
+
if properties:
|
|
285
|
+
payload["properties"] = properties
|
|
286
|
+
|
|
287
|
+
return payload
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _build_template_payload(
|
|
291
|
+
name: str,
|
|
292
|
+
template_type: str,
|
|
293
|
+
statements: Optional[List[Dict[str, Any]]] = None,
|
|
294
|
+
properties: Optional[Dict[str, str]] = None,
|
|
295
|
+
) -> Dict[str, Any]:
|
|
296
|
+
"""Build a policy template creation/update payload.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
name: Template name
|
|
300
|
+
template_type: Template type (user|service)
|
|
301
|
+
statements: List of statement dictionaries
|
|
302
|
+
properties: Custom properties dictionary
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Template payload for API request
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
ValueError: If required fields are missing
|
|
309
|
+
"""
|
|
310
|
+
if not statements:
|
|
311
|
+
raise ValueError("statements are required for policy templates")
|
|
312
|
+
|
|
313
|
+
is_valid, error_msg = _validate_statements(statements)
|
|
314
|
+
if not is_valid:
|
|
315
|
+
raise ValueError(error_msg)
|
|
316
|
+
|
|
317
|
+
payload: Dict[str, Any] = {
|
|
318
|
+
"name": name,
|
|
319
|
+
"type": template_type,
|
|
320
|
+
"statements": statements,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if properties:
|
|
324
|
+
payload["properties"] = properties
|
|
325
|
+
|
|
326
|
+
return payload
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _display_policy_details(policy: Dict[str, Any], format_output: str = "table") -> None:
|
|
330
|
+
"""Display detailed policy information.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
policy: Policy dictionary from API
|
|
334
|
+
format_output: Output format (table or json)
|
|
335
|
+
"""
|
|
336
|
+
import json
|
|
337
|
+
|
|
338
|
+
if format_output.lower() == "json":
|
|
339
|
+
click.echo(json.dumps(policy, indent=2))
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
# Table format
|
|
343
|
+
click.echo(f"\n✓ Policy: {policy.get('name', 'N/A')}")
|
|
344
|
+
click.echo("-" * 80)
|
|
345
|
+
click.echo(f" ID: {policy.get('id', 'N/A')}")
|
|
346
|
+
click.echo(f" Type: {policy.get('type', 'N/A')}")
|
|
347
|
+
click.echo(f" Built-in: {'Yes' if policy.get('builtIn') else 'No'}")
|
|
348
|
+
click.echo(f" Owner ID: {policy.get('userId', 'N/A')}")
|
|
349
|
+
click.echo(f" Created: {policy.get('created', 'N/A')}")
|
|
350
|
+
click.echo(f" Updated: {policy.get('updated', 'N/A')}")
|
|
351
|
+
|
|
352
|
+
# Show template reference if present
|
|
353
|
+
template_id = policy.get("templateId")
|
|
354
|
+
if template_id:
|
|
355
|
+
click.echo(f"\n Template-based Policy:")
|
|
356
|
+
click.echo(f" Template ID: {template_id}")
|
|
357
|
+
click.echo(f" Workspace: {policy.get('workspace', 'N/A')}")
|
|
358
|
+
|
|
359
|
+
# Show properties if present
|
|
360
|
+
properties = policy.get("properties", {})
|
|
361
|
+
if properties:
|
|
362
|
+
click.echo(f"\n Properties:")
|
|
363
|
+
for key, val in properties.items():
|
|
364
|
+
click.echo(f" {key}: {val}")
|
|
365
|
+
|
|
366
|
+
# Show statements
|
|
367
|
+
statements = policy.get("statements", [])
|
|
368
|
+
if statements:
|
|
369
|
+
click.echo(f"\n Statements ({len(statements)}):")
|
|
370
|
+
click.echo(_format_statements_for_display(statements))
|
|
371
|
+
|
|
372
|
+
click.echo()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _display_template_details(template: Dict[str, Any], format_output: str = "table") -> None:
|
|
376
|
+
"""Display detailed template information.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
template: Policy template dictionary from API
|
|
380
|
+
format_output: Output format (table or json)
|
|
381
|
+
"""
|
|
382
|
+
import json
|
|
383
|
+
|
|
384
|
+
if format_output.lower() == "json":
|
|
385
|
+
click.echo(json.dumps(template, indent=2))
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
# Table format
|
|
389
|
+
click.echo(f"\n✓ Policy Template: {template.get('name', 'N/A')}")
|
|
390
|
+
click.echo("-" * 80)
|
|
391
|
+
click.echo(f" ID: {template.get('id', 'N/A')}")
|
|
392
|
+
click.echo(f" Type: {template.get('type', 'N/A')}")
|
|
393
|
+
click.echo(f" Built-in: {'Yes' if template.get('builtIn') else 'No'}")
|
|
394
|
+
click.echo(f" Owner ID: {template.get('userId', 'N/A')}")
|
|
395
|
+
click.echo(f" Created: {template.get('created', 'N/A')}")
|
|
396
|
+
click.echo(f" Updated: {template.get('updated', 'N/A')}")
|
|
397
|
+
|
|
398
|
+
# Show properties if present
|
|
399
|
+
properties = template.get("properties", {})
|
|
400
|
+
if properties:
|
|
401
|
+
click.echo(f"\n Properties:")
|
|
402
|
+
for key, val in properties.items():
|
|
403
|
+
click.echo(f" {key}: {val}")
|
|
404
|
+
|
|
405
|
+
# Show statements
|
|
406
|
+
statements = template.get("statements", [])
|
|
407
|
+
if statements:
|
|
408
|
+
click.echo(f"\n Statements ({len(statements)}):")
|
|
409
|
+
click.echo(_format_statements_for_display(statements))
|
|
410
|
+
|
|
411
|
+
click.echo()
|