pvw-cli 1.0.8__py3-none-any.whl → 1.0.10__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.

Potentially problematic release.


This version of pvw-cli might be problematic. Click here for more details.

purviewcli/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "1.0.8"
1
+ __version__ = "1.0.10"
2
2
 
3
3
  # Import main client modules
4
4
  from .client import *
@@ -0,0 +1,250 @@
1
+ """
2
+ Health CLI commands for Microsoft Purview Unified Catalog
3
+ """
4
+
5
+ import click
6
+ from rich.table import Table
7
+ from rich.console import Console
8
+ from purviewcli.client._health import Health
9
+ import re
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.group()
15
+ def health():
16
+ """Health monitoring and governance recommendations."""
17
+ pass
18
+
19
+
20
+ @health.command()
21
+ @click.option("--domain-id", help="Filter by governance domain ID")
22
+ @click.option("--severity", help="Filter by severity: High, Medium, Low")
23
+ @click.option("--status", help="Filter by status: NotStarted, InProgress, Resolved, Dismissed")
24
+ @click.option("--finding-type", help="Filter by finding type (e.g., 'Estate Curation')")
25
+ @click.option("--target-entity-type", help="Filter by target entity type (e.g., DataProduct, Term)")
26
+ def query(domain_id, severity, status, finding_type, target_entity_type):
27
+ """Query health actions (findings and recommendations)."""
28
+ client = Health()
29
+
30
+ args = {
31
+ "--domain-id": [domain_id] if domain_id else [""],
32
+ "--severity": [severity] if severity else [""],
33
+ "--status": [status] if status else [""],
34
+ "--finding-type": [finding_type] if finding_type else [""],
35
+ "--target-entity-type": [target_entity_type] if target_entity_type else [""]
36
+ }
37
+
38
+ result = client.query_health_actions(args)
39
+
40
+ # get_data() in endpoint.py returns just the data part
41
+ if result and isinstance(result, dict):
42
+ actions = result.get("value", [])
43
+
44
+ if not actions:
45
+ console.print("[yellow]No health actions found matching the filters.[/yellow]")
46
+ return
47
+
48
+ # Create summary table
49
+ table = Table(title=f"Health Actions ({len(actions)} found)", show_lines=True)
50
+ table.add_column("ID", style="cyan", no_wrap=False)
51
+ table.add_column("Finding", style="white", no_wrap=False)
52
+ table.add_column("Severity", style="yellow")
53
+ table.add_column("Status", style="green")
54
+ table.add_column("Target", style="magenta", no_wrap=False)
55
+ table.add_column("Domain", style="blue", no_wrap=False)
56
+
57
+ for action in actions:
58
+ # Color severity
59
+ severity_text = action.get("severity", "N/A")
60
+ if severity_text == "High":
61
+ severity_style = "[red]High[/red]"
62
+ elif severity_text == "Medium":
63
+ severity_style = "[yellow]Medium[/yellow]"
64
+ else:
65
+ severity_style = "[green]Low[/green]"
66
+
67
+ # Color status
68
+ status_text = action.get("status", "N/A")
69
+ if status_text == "NotStarted":
70
+ status_style = "[red]Not Started[/red]"
71
+ elif status_text == "InProgress":
72
+ status_style = "[yellow]In Progress[/yellow]"
73
+ elif status_text == "Resolved":
74
+ status_style = "[green]Resolved[/green]"
75
+ else:
76
+ status_style = status_text
77
+
78
+ # Truncate IDs for display
79
+ action_id = action.get("id", "N/A")
80
+ short_id = action_id[:13] + "..." if len(action_id) > 16 else action_id
81
+
82
+ domain_id_val = action.get("domainId", "N/A")
83
+ short_domain = domain_id_val[:13] + "..." if len(domain_id_val) > 16 else domain_id_val
84
+
85
+ table.add_row(
86
+ short_id,
87
+ action.get("findingName", "N/A"),
88
+ severity_style,
89
+ status_style,
90
+ action.get("targetEntityType", "N/A"),
91
+ short_domain
92
+ )
93
+
94
+ console.print(table)
95
+ console.print(f"\n[dim]Showing {len(actions)} health action(s)[/dim]")
96
+ console.print("[dim]Use 'pvcli uc health show --action-id <id>' for details[/dim]")
97
+ else:
98
+ console.print("[red]Failed to retrieve health actions.[/red]")
99
+
100
+
101
+ @health.command()
102
+ @click.option("--action-id", required=True, help="Health action ID")
103
+ def show(action_id):
104
+ """Show detailed information about a health action."""
105
+ client = Health()
106
+ args = {"--action-id": [action_id]}
107
+
108
+ result = client.get_health_action(args)
109
+
110
+ # get_data() returns just the data part
111
+ if result and isinstance(result, dict) and "id" in result:
112
+ action = result
113
+
114
+ console.print(f"\n[bold cyan]Health Action Details[/bold cyan]\n")
115
+
116
+ # Basic info
117
+ console.print(f"[bold]ID:[/bold] {action.get('id', 'N/A')}")
118
+ console.print(f"[bold]Finding ID:[/bold] {action.get('findingId', 'N/A')}")
119
+ console.print(f"[bold]Name:[/bold] {action.get('findingName', 'N/A')}")
120
+
121
+ # Severity with color
122
+ severity = action.get("severity", "N/A")
123
+ severity_color = "red" if severity == "High" else "yellow" if severity == "Medium" else "green"
124
+ console.print(f"[bold]Severity:[/bold] [{severity_color}]{severity}[/{severity_color}]")
125
+
126
+ # Status with color
127
+ status = action.get("status", "N/A")
128
+ status_color = "red" if status == "NotStarted" else "yellow" if status == "InProgress" else "green"
129
+ console.print(f"[bold]Status:[/bold] [{status_color}]{status}[/{status_color}]")
130
+
131
+ # Category and types
132
+ console.print(f"\n[bold]Category:[/bold] {action.get('category', 'N/A')}")
133
+ console.print(f"[bold]Finding Type:[/bold] {action.get('findingType', 'N/A')}")
134
+ console.print(f"[bold]Finding SubType:[/bold] {action.get('findingSubType', 'N/A')}")
135
+
136
+ # Target
137
+ console.print(f"\n[bold]Target Entity Type:[/bold] {action.get('targetEntityType', 'N/A')}")
138
+ console.print(f"[bold]Target Entity ID:[/bold] {action.get('targetEntityId', 'N/A')}")
139
+ console.print(f"[bold]Domain ID:[/bold] {action.get('domainId', 'N/A')}")
140
+
141
+ # Recommendation
142
+ recommendation = action.get("recommendation", "")
143
+ if recommendation:
144
+ console.print(f"\n[bold]Recommendation:[/bold]\n{recommendation}")
145
+
146
+ # Reason
147
+ reason = action.get("reason", "")
148
+ if reason:
149
+ console.print(f"\n[bold]Reason:[/bold]\n{reason}")
150
+
151
+ # Assignment
152
+ assigned_to = action.get("assignedTo", [])
153
+ if assigned_to:
154
+ console.print(f"\n[bold]Assigned To:[/bold]")
155
+ for user_id in assigned_to:
156
+ console.print(f" • {user_id}")
157
+ else:
158
+ console.print(f"\n[bold]Assigned To:[/bold] [yellow]Not assigned[/yellow]")
159
+
160
+ # System data
161
+ system_data = action.get("systemData", {})
162
+ if system_data:
163
+ console.print(f"\n[bold]System Information:[/bold]")
164
+ console.print(f" Created At: {system_data.get('createdAt', 'N/A')}")
165
+ console.print(f" Created By: {system_data.get('createdBy', 'N/A')}")
166
+ console.print(f" Last Modified: {system_data.get('lastModifiedAt', 'N/A')}")
167
+ console.print(f" Last Modified By: {system_data.get('lastModifiedBy', 'N/A')}")
168
+ console.print(f" Last Hint At: {system_data.get('lastHintAt', 'N/A')}")
169
+
170
+ console.print()
171
+ else:
172
+ console.print(f"[red]Failed to retrieve health action: {action_id}[/red]")
173
+
174
+
175
+ @health.command()
176
+ @click.option("--action-id", required=True, help="Health action ID")
177
+ @click.option("--status", help="New status: NotStarted, InProgress, Resolved, Dismissed")
178
+ @click.option("--assigned-to", help="User ID or email to assign to")
179
+ @click.option("--reason", help="Reason for the update")
180
+ def update(action_id, status, assigned_to, reason):
181
+ """Update a health action (status, assignment, etc.)."""
182
+ if not status and not assigned_to and not reason:
183
+ console.print("[red]Error: At least one of --status, --assigned-to, or --reason must be provided.[/red]")
184
+ return
185
+
186
+ client = Health()
187
+ args = {
188
+ "--action-id": [action_id],
189
+ "--status": [status] if status else [""],
190
+ "--assigned-to": [assigned_to] if assigned_to else [""],
191
+ "--reason": [reason] if reason else [""]
192
+ }
193
+
194
+ result = client.update_health_action(args)
195
+
196
+ if result and result.get("status") == "success":
197
+ console.print(f"[green]✓[/green] Health action updated successfully: {action_id}")
198
+ if status:
199
+ console.print(f" Status: {status}")
200
+ if assigned_to:
201
+ console.print(f" Assigned to: {assigned_to}")
202
+ if reason:
203
+ console.print(f" Reason: {reason}")
204
+ else:
205
+ console.print(f"[red]Failed to update health action: {action_id}[/red]")
206
+
207
+
208
+ @health.command()
209
+ @click.option("--action-id", required=True, help="Health action ID")
210
+ @click.confirmation_option(prompt="Are you sure you want to delete this health action?")
211
+ def delete(action_id):
212
+ """Delete a health action."""
213
+ client = Health()
214
+ args = {"--action-id": [action_id]}
215
+
216
+ result = client.delete_health_action(args)
217
+
218
+ if result and result.get("status") == "success":
219
+ console.print(f"[green]✓[/green] Health action deleted successfully: {action_id}")
220
+ else:
221
+ console.print(f"[red]Failed to delete health action: {action_id}[/red]")
222
+
223
+
224
+ @health.command()
225
+ @click.option("--domain-id", help="Get summary for specific domain")
226
+ def summary(domain_id):
227
+ """Get health summary statistics."""
228
+ client = Health()
229
+ args = {"--domain-id": [domain_id] if domain_id else [""]}
230
+
231
+ result = client.get_health_summary(args)
232
+
233
+ if result and result.get("data"):
234
+ summary_data = result["data"]
235
+
236
+ console.print("\n[bold cyan]Health Summary[/bold cyan]\n")
237
+
238
+ # Display summary statistics
239
+ console.print(f"Total Actions: {summary_data.get('total', 'N/A')}")
240
+ console.print(f"High Severity: [red]{summary_data.get('high', 'N/A')}[/red]")
241
+ console.print(f"Medium Severity: [yellow]{summary_data.get('medium', 'N/A')}[/yellow]")
242
+ console.print(f"Low Severity: [green]{summary_data.get('low', 'N/A')}[/green]")
243
+ console.print(f"\nNot Started: [red]{summary_data.get('notStarted', 'N/A')}[/red]")
244
+ console.print(f"In Progress: [yellow]{summary_data.get('inProgress', 'N/A')}[/yellow]")
245
+ console.print(f"Resolved: [green]{summary_data.get('resolved', 'N/A')}[/green]")
246
+
247
+ console.print()
248
+ else:
249
+ console.print("[yellow]Summary endpoint may not be available or no data returned.[/yellow]")
250
+ console.print("[dim]Try using 'pvcli uc health query' to see all actions.[/dim]")
purviewcli/cli/search.py CHANGED
@@ -21,39 +21,226 @@ options:
21
21
  CLI for advanced search and discovery
22
22
  """
23
23
  import click
24
+ import json
25
+ from rich.console import Console
26
+ from rich.table import Table
24
27
  from purviewcli.client._search import Search
25
28
 
29
+ console = Console()
30
+
26
31
  @click.group()
27
32
  def search():
28
33
  """Search and discover assets"""
29
34
  pass
30
35
 
36
+ def _format_json_output(data):
37
+ """Format JSON output with syntax highlighting using Rich"""
38
+ from rich.console import Console
39
+ from rich.syntax import Syntax
40
+ import json
41
+
42
+ console = Console()
43
+
44
+ # Pretty print JSON with syntax highlighting
45
+ json_str = json.dumps(data, indent=2)
46
+ syntax = Syntax(json_str, "json", theme="monokai", line_numbers=True)
47
+ console.print(syntax)
48
+
49
+ def _format_detailed_output(data):
50
+ """Format search results with detailed information in readable format"""
51
+ from rich.console import Console
52
+ from rich.panel import Panel
53
+ from rich.text import Text
54
+
55
+ console = Console()
56
+
57
+ # Extract results data
58
+ count = data.get('@search.count', 0)
59
+ items = data.get('value', [])
60
+
61
+ if not items:
62
+ console.print("[yellow]No results found[/yellow]")
63
+ return
64
+
65
+ console.print(f"\n[bold cyan]Search Results: {len(items)} of {count} total[/bold cyan]\n")
66
+
67
+ for i, item in enumerate(items, 1):
68
+ # Create a panel for each result
69
+ details = []
70
+
71
+ # Basic information
72
+ details.append(f"[bold cyan]Name:[/bold cyan] {item.get('name', 'N/A')}")
73
+ details.append(f"[bold green]Type:[/bold green] {item.get('entityType', 'N/A')}")
74
+ details.append(f"[bold yellow]ID:[/bold yellow] {item.get('id', 'N/A')}")
75
+
76
+ # Collection
77
+ if 'collection' in item and item['collection']:
78
+ collection_name = item['collection'].get('name', 'N/A')
79
+ else:
80
+ collection_name = item.get('collectionId', 'N/A')
81
+ details.append(f"[bold blue]Collection:[/bold blue] {collection_name}")
82
+
83
+ # Qualified Name
84
+ details.append(f"[bold white]Qualified Name:[/bold white] {item.get('qualifiedName', 'N/A')}")
85
+
86
+ # Classifications
87
+ if 'classification' in item and item['classification']:
88
+ classifications = []
89
+ for cls in item['classification']:
90
+ if isinstance(cls, dict):
91
+ classifications.append(cls.get('typeName', str(cls)))
92
+ else:
93
+ classifications.append(str(cls))
94
+ details.append(f"[bold magenta]Classifications:[/bold magenta] {', '.join(classifications)}")
95
+
96
+ # Additional metadata
97
+ if 'updateTime' in item:
98
+ details.append(f"[bold dim]Last Updated:[/bold dim] {item.get('updateTime')}")
99
+ if 'createTime' in item:
100
+ details.append(f"[bold dim]Created:[/bold dim] {item.get('createTime')}")
101
+ if 'updateBy' in item:
102
+ details.append(f"[bold dim]Updated By:[/bold dim] {item.get('updateBy')}")
103
+
104
+ # Search score
105
+ if '@search.score' in item:
106
+ details.append(f"[bold dim]Search Score:[/bold dim] {item.get('@search.score'):.2f}")
107
+
108
+ # Create panel
109
+ panel_content = "\n".join(details)
110
+ panel = Panel(
111
+ panel_content,
112
+ title=f"[bold]{i}. {item.get('name', 'Unknown')}[/bold]",
113
+ border_style="blue"
114
+ )
115
+ console.print(panel)
116
+
117
+ # Add pagination hint if there are more results
118
+ if len(items) < count:
119
+ console.print(f"\n💡 [dim]More results available. Use --offset to paginate.[/dim]")
120
+
121
+ return
122
+
123
+ def _format_search_results(data, show_ids=False):
124
+ """Format search results as a nice table using Rich"""
125
+ from rich.console import Console
126
+ from rich.table import Table
127
+
128
+ console = Console()
129
+
130
+ # Extract results data
131
+ count = data.get('@search.count', 0)
132
+ items = data.get('value', [])
133
+
134
+ if not items:
135
+ console.print("[yellow]No results found[/yellow]")
136
+ return
137
+
138
+ # Create table
139
+ table = Table(title=f"Search Results ({len(items)} of {count} total)")
140
+ table.add_column("Name", style="cyan", min_width=15, max_width=25)
141
+ table.add_column("Type", style="green", min_width=15, max_width=20)
142
+ table.add_column("Collection", style="blue", min_width=12, max_width=20)
143
+ table.add_column("Classifications", style="magenta", min_width=15, max_width=30)
144
+
145
+ if show_ids:
146
+ table.add_column("ID", style="yellow", min_width=36, max_width=36)
147
+
148
+ table.add_column("Qualified Name", style="white", min_width=30)
149
+
150
+ for item in items:
151
+ # Extract entity information
152
+ name = item.get('name', 'N/A')
153
+ entity_type = item.get('entityType', 'N/A')
154
+ entity_id = item.get('id', 'N/A')
155
+ qualified_name = item.get('qualifiedName', 'N/A')
156
+
157
+ # Truncate long qualified names for better display
158
+ if len(qualified_name) > 60:
159
+ qualified_name = qualified_name[:57] + "..."
160
+
161
+ # Handle collection
162
+ collection = 'N/A'
163
+ if 'collection' in item and item['collection']:
164
+ collection = item['collection'].get('name', 'N/A')
165
+
166
+ # Handle classifications - truncate long classification lists
167
+ classifications = []
168
+ if 'classification' in item and item['classification']:
169
+ for cls in item['classification']:
170
+ if isinstance(cls, dict):
171
+ cls_name = cls.get('typeName', str(cls))
172
+ # Simplify Microsoft classifications for display
173
+ if cls_name.startswith('MICROSOFT.'):
174
+ cls_name = cls_name.replace('MICROSOFT.', 'MS.')
175
+ classifications.append(cls_name)
176
+ else:
177
+ classifications.append(str(cls))
178
+
179
+ # Truncate classifications if too long
180
+ cls_display = ", ".join(classifications) if classifications else ""
181
+ if len(cls_display) > 40:
182
+ cls_display = cls_display[:37] + "..."
183
+
184
+ # Build row data
185
+ row_data = [name, entity_type, collection, cls_display]
186
+ if show_ids:
187
+ row_data.append(entity_id)
188
+ row_data.append(qualified_name)
189
+
190
+ # Add row to table
191
+ table.add_row(*row_data)
192
+
193
+ # Print the table
194
+ console.print(table)
195
+
196
+ # Add pagination hint if there are more results
197
+ if len(items) < count:
198
+ console.print(f"\n💡 More results available. Use --offset to paginate.")
199
+
200
+ return
201
+
31
202
  def _invoke_search_method(method_name, **kwargs):
32
203
  search_client = Search()
33
204
  method = getattr(search_client, method_name)
205
+
206
+ # Extract formatting options, don't pass to API
207
+ show_ids = kwargs.pop('show_ids', False)
208
+ output_json = kwargs.pop('output_json', False)
209
+ detailed = kwargs.pop('detailed', False)
210
+
34
211
  args = {f'--{k}': v for k, v in kwargs.items() if v is not None}
35
212
  try:
36
213
  result = method(args)
37
- click.echo(result)
214
+ # Choose output format
215
+ if output_json:
216
+ _format_json_output(result)
217
+ elif detailed and method_name in ['searchQuery', 'searchBrowse', 'searchSuggest', 'searchAutoComplete']:
218
+ _format_detailed_output(result)
219
+ elif method_name in ['searchQuery', 'searchBrowse', 'searchSuggest', 'searchAutoComplete']:
220
+ _format_search_results(result, show_ids=show_ids)
221
+ else:
222
+ _format_json_output(result)
38
223
  except Exception as e:
39
- click.echo(f"[ERROR] {e}", err=True)
224
+ console.print(f"[red]ERROR:[/red] {str(e)}")
40
225
 
41
226
  @search.command()
42
227
  @click.option('--keywords', required=False)
43
228
  @click.option('--limit', required=False, type=int, default=25)
44
229
  @click.option('--filterFile', required=False, type=click.Path(exists=True))
45
- def autocomplete(keywords, limit, filterfile):
230
+ @click.option('--json', 'output_json', is_flag=True, help='Show full JSON details instead of table')
231
+ def autocomplete(keywords, limit, filterfile, output_json):
46
232
  """Autocomplete search suggestions"""
47
- _invoke_search_method('searchAutoComplete', keywords=keywords, limit=limit, filterFile=filterfile)
233
+ _invoke_search_method('searchAutoComplete', keywords=keywords, limit=limit, filterFile=filterfile, output_json=output_json)
48
234
 
49
235
  @search.command()
50
236
  @click.option('--entityType', required=False)
51
237
  @click.option('--path', required=False)
52
238
  @click.option('--limit', required=False, type=int, default=25)
53
239
  @click.option('--offset', required=False, type=int, default=0)
54
- def browse(entitytype, path, limit, offset):
240
+ @click.option('--json', 'output_json', is_flag=True, help='Show full JSON details instead of table')
241
+ def browse(entitytype, path, limit, offset, output_json):
55
242
  """Browse entities by type or path"""
56
- _invoke_search_method('searchBrowse', entityType=entitytype, path=path, limit=limit, offset=offset)
243
+ _invoke_search_method('searchBrowse', entityType=entitytype, path=path, limit=limit, offset=offset, output_json=output_json)
57
244
 
58
245
  @search.command()
59
246
  @click.option('--keywords', required=False)
@@ -61,17 +248,21 @@ def browse(entitytype, path, limit, offset):
61
248
  @click.option('--offset', required=False, type=int, default=0)
62
249
  @click.option('--filterFile', required=False, type=click.Path(exists=True))
63
250
  @click.option('--facets-file', required=False, type=click.Path(exists=True))
64
- def query(keywords, limit, offset, filterfile, facets_file):
251
+ @click.option('--show-ids', is_flag=True, help='Show entity IDs in the results')
252
+ @click.option('--json', 'output_json', is_flag=True, help='Show full JSON details instead of table')
253
+ @click.option('--detailed', is_flag=True, help='Show detailed information in readable format')
254
+ def query(keywords, limit, offset, filterfile, facets_file, show_ids, output_json, detailed):
65
255
  """Run a search query"""
66
- _invoke_search_method('searchQuery', keywords=keywords, limit=limit, offset=offset, filterFile=filterfile, facets_file=facets_file)
256
+ _invoke_search_method('searchQuery', keywords=keywords, limit=limit, offset=offset, filterFile=filterfile, facets_file=facets_file, show_ids=show_ids, output_json=output_json, detailed=detailed)
67
257
 
68
258
  @search.command()
69
259
  @click.option('--keywords', required=False)
70
260
  @click.option('--limit', required=False, type=int, default=25)
71
261
  @click.option('--filterFile', required=False, type=click.Path(exists=True))
72
- def suggest(keywords, limit, filterfile):
262
+ @click.option('--json', 'output_json', is_flag=True, help='Show full JSON details instead of table')
263
+ def suggest(keywords, limit, filterfile, output_json):
73
264
  """Get search suggestions"""
74
- _invoke_search_method('searchSuggest', keywords=keywords, limit=limit, filterFile=filterfile)
265
+ _invoke_search_method('searchSuggest', keywords=keywords, limit=limit, filterFile=filterfile, output_json=output_json)
75
266
 
76
267
  @search.command()
77
268
  @click.option('--keywords', required=False)