perspective-cli 0.1.0__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.
@@ -0,0 +1 @@
1
+ """Perspective CLI."""
perspective/config.py ADDED
@@ -0,0 +1,240 @@
1
+ """Manage Perspective instance configuration."""
2
+
3
+ from contextlib import suppress
4
+ from pathlib import Path
5
+ from urllib.parse import urljoin
6
+
7
+ from requests.models import Response
8
+ from rich.panel import Panel
9
+ import typer
10
+ import yaml
11
+
12
+ from perspective.models.configs import Config
13
+ from perspective.utils import console, send_request
14
+ from perspective.utils.options import ConfigDir, DryRun, Force, PerspectiveURL
15
+
16
+
17
+ app = typer.Typer(
18
+ name="config", no_args_is_help=True, pretty_exceptions_show_locals=False
19
+ )
20
+
21
+ CONFIG_YAML_EXAMPLE = """# Example:
22
+ #
23
+ # groups:
24
+ # - meta_key: "domain"
25
+ # slug: "domains"
26
+ # label_plural: "Domains"
27
+ # label_singular: "Domain"
28
+ # icon: "Cube"
29
+ # in_sidebar: true
30
+ # visible: true
31
+ # - meta_key: "true_source"
32
+ # slug: "sources"
33
+ # label_plural: "Sources"
34
+ # label_singular: "Source"
35
+ # icon: "Cloud"
36
+ # in_sidebar: true
37
+ """
38
+
39
+ OWNERS_YAML_EXAMPLE = """# Example:
40
+ #
41
+ # owners:
42
+ # - email: "some@one.com"
43
+ # first_name: "Dave"
44
+ # last_name: "Smith"
45
+ # title: "Director"
46
+ # - email: "other@person.com"
47
+ # first_name: "Michelle"
48
+ # last_name: "Dunne"
49
+ # title: "CTO"
50
+ # - email: "someone@else.com"
51
+ # first_name: "Dana"
52
+ # last_name: "Pawlak"
53
+ # title: "HR Manager"
54
+ """
55
+
56
+
57
+ def init_config(config_dir: Path | str = "./.perspective", force: bool = False) -> None:
58
+ """Initialize configuration files in the specified directory.
59
+
60
+ Args:
61
+ config_dir (Path | str, optional): The directory where configuration files
62
+ will be created. Defaults to "./.perspective".
63
+ force (bool, optional): If True, existing configuration files will be
64
+ overwritten. Defaults to False.
65
+
66
+ Raises:
67
+ FileExistsError: If configuration files already exist and `force` is not set to
68
+ True.
69
+ """
70
+ config_dir = Path(config_dir)
71
+
72
+ config_path = config_dir / "config.yaml"
73
+ owners_path = config_dir / "owners.yaml"
74
+
75
+ if force:
76
+ config_path.unlink(missing_ok=True)
77
+ owners_path.unlink(missing_ok=True)
78
+ with suppress(FileNotFoundError):
79
+ config_dir.rmdir()
80
+
81
+ if not config_path.exists() and not owners_path.exists():
82
+ config_dir.mkdir(exist_ok=True)
83
+ config_path.touch(exist_ok=False)
84
+ owners_path.touch(exist_ok=False)
85
+ else:
86
+ raise FileExistsError
87
+
88
+ config_path.write_text(CONFIG_YAML_EXAMPLE)
89
+ owners_path.write_text(OWNERS_YAML_EXAMPLE)
90
+
91
+
92
+ def get_config(config_dir: Path | str = "./.perspective") -> Config | None:
93
+ """Retrieve configuration data from YAML files in the specified directory.
94
+
95
+ Args:
96
+ config_dir (Path | str, optional): The directory containing the
97
+ configuration files. Defaults to "./.perspective".
98
+
99
+ Returns:
100
+ optional[Config]: The configuration object if the configuration is successfully
101
+ loaded, otherwise None.
102
+
103
+ Raises:
104
+ FileNotFoundError: If the configuration files are missing.
105
+ typer.Abort: If there is an error parsing the YAML files.
106
+ """
107
+ config_dir = Path(config_dir)
108
+
109
+ config_path = config_dir / "config.yaml"
110
+ owners_path = config_dir / "owners.yaml"
111
+
112
+ config_missing = True
113
+ owners_missing = True
114
+
115
+ config_dict = {}
116
+ config_data = {}
117
+ owners_data = {}
118
+
119
+ if config_path.exists():
120
+ config_missing = False
121
+ with config_path.open("r") as f:
122
+ try:
123
+ config_data: dict | None = yaml.safe_load(f)
124
+ except yaml.YAMLError as e:
125
+ console.print(f"Error parsing YAML file: {e}")
126
+ raise typer.Abort() from e
127
+
128
+ if owners_path.exists():
129
+ owners_missing = False
130
+ with owners_path.open("r") as f:
131
+ try:
132
+ owners_data: dict | None = yaml.safe_load(f)
133
+
134
+ except yaml.YAMLError as e:
135
+ console.print(f"Error parsing YAML file: {e}")
136
+ raise typer.Abort() from e
137
+
138
+ if config_missing and owners_missing:
139
+ raise FileNotFoundError
140
+
141
+ if config_data is not None:
142
+ config_dict.update(config_data)
143
+
144
+ if owners_data is not None:
145
+ config_dict.update(owners_data)
146
+
147
+ return Config(**config_dict)
148
+
149
+
150
+ def send_config(config: Config, perspective_url: str, verify: bool = True) -> Response:
151
+ """Send configuration data to a specified URL.
152
+
153
+ Args:
154
+ config (Config): The configuration data to be sent.
155
+ perspective_url (str): The URL where the configuration data will be sent.
156
+ verify (bool): Whether to verify the server's TLS certificate. Defaults to True.
157
+
158
+ Returns:
159
+ Response: The response from the server after sending the configuration data.
160
+
161
+ Raises:
162
+ typer.Exit: If there is an error in sending the configuration data.
163
+ """
164
+ console.print(Panel("[yellow]Sending config info to Perspective...[/yellow]"))
165
+
166
+ return send_request(
167
+ url=urljoin(perspective_url, "config/"),
168
+ payload=config.model_dump(by_alias=True),
169
+ verify=verify,
170
+ )
171
+
172
+
173
+ @app.command(help="Initialize the configuration.")
174
+ def init(config_dir: Path = ConfigDir, force: bool = Force) -> None:
175
+ """Initialize the configuration.
176
+
177
+ Args:
178
+ config_dir (Path): The directory to write the configuration files.
179
+ force (bool): If True, overwrite the configuration if it already exists.
180
+
181
+ Raises FileExistsError if the configuration already exists and 'force' is not True.
182
+ """
183
+ try:
184
+ init_config(config_dir=config_dir, force=force)
185
+ console.print(f"[green]Config initialized at[/green] {config_dir}")
186
+ except FileExistsError as e:
187
+ console.print(
188
+ f"[red]Error![/red] [red]Config files already exist at[/red] {config_dir}\n"
189
+ f"[yellow]If you want to override run with flag [/yellow][red]--force/-f[/red]"
190
+ )
191
+ raise typer.Exit(1) from e
192
+
193
+
194
+ @app.command(help="Display the current configuration information.")
195
+ def show(config_dir: Path = ConfigDir) -> None:
196
+ """Display current configuration from the specified directory."""
197
+ try:
198
+ config = get_config(config_dir=config_dir)
199
+ console.print(config)
200
+ except FileNotFoundError as e:
201
+ console.print(
202
+ f"[red]Error![/red] [red]Config files not found at[/red] {config_dir}\n"
203
+ "[yellow]To generate config files use [/yellow][white]'luma config init'[/white]"
204
+ )
205
+ raise typer.Exit(1) from e
206
+
207
+
208
+ @app.command(help="Send the current configuration information to luma")
209
+ def send(
210
+ config_dir: Path = ConfigDir,
211
+ perspective_url: str = PerspectiveURL,
212
+ dry_run: bool = DryRun,
213
+ ) -> None:
214
+ """Send configuration to the specified Perspective URL.
215
+
216
+ In dry run mode, the configuration is printed but not sent.
217
+ """
218
+ try:
219
+ config = get_config(config_dir=config_dir)
220
+
221
+ if dry_run:
222
+ console.print(config.model_dump(by_alias=True))
223
+ return
224
+
225
+ if config:
226
+ response = send_config(config=config, perspective_url=perspective_url)
227
+ if not response.ok:
228
+ raise typer.Exit(1)
229
+ else:
230
+ console.print(
231
+ f"[red]No Config detected under {config_dir}[/red]\n"
232
+ f"[yellow]To generate config files use [/yellow][white]'perspective config init'[/white]"
233
+ )
234
+
235
+ except FileNotFoundError as e:
236
+ console.print(
237
+ f"[red]Error![/red] [red]Config files not found at[/red] {config_dir}\n"
238
+ f"[yellow]To generate config files use [/yellow][white]'perspective config init'[/white]"
239
+ )
240
+ raise typer.Exit(1) from e
@@ -0,0 +1,15 @@
1
+ """Exceptions for CLI commands."""
2
+
3
+
4
+ class DbtArtifactValidationError(Exception):
5
+ """Exception raised for errors in the dbt artifact validation process."""
6
+
7
+ def __init__(self, version: str | None = None): # noqa: D107
8
+ message = f"Unsupported dbt version: {version}."
9
+ super().__init__(message)
10
+
11
+
12
+ class ExtractionError(Exception):
13
+ """Raised when an error occurs during data extraction."""
14
+
15
+ pass
@@ -0,0 +1,150 @@
1
+ """Ingest dbt metadata into Luma."""
2
+
3
+ from collections.abc import Callable
4
+ import json
5
+ from pathlib import Path
6
+ import time
7
+ from typing import Any
8
+ from urllib.parse import urljoin
9
+
10
+ from dbt_artifacts_parser.parser import (
11
+ parse_catalog,
12
+ parse_manifest,
13
+ parse_run_results,
14
+ )
15
+ from pydantic import ValidationError
16
+ from rich.panel import Panel
17
+ from typer import Context, Exit, Typer
18
+
19
+ from perspective.exceptions import DbtArtifactValidationError
20
+ from perspective.utils import (
21
+ IngestionStatus,
22
+ check_ingestion_results,
23
+ check_ingestion_status,
24
+ console,
25
+ send_request,
26
+ )
27
+ from perspective.utils.options import (
28
+ DryRun,
29
+ Follow,
30
+ IsDbtTestResultsIngestion,
31
+ MetadataDir,
32
+ PerspectiveURL,
33
+ )
34
+
35
+
36
+ app = Typer(no_args_is_help=True, pretty_exceptions_show_locals=False)
37
+
38
+
39
+ def _validate_artifacts(artifacts: dict[str, Callable]) -> dict[str, Any]:
40
+ """Validate and parse dbt artifacts."""
41
+ file_names = [Path(path).name for path in artifacts]
42
+ console.print(
43
+ Panel(
44
+ f"📦 Validating dbt artifacts: [cyan]{file_names}[/cyan] for ingestion..."
45
+ )
46
+ )
47
+ parsed_artifacts = {}
48
+ for artifact_path, parsing_func in artifacts.items():
49
+ try:
50
+ with Path(artifact_path).open("r", encoding="utf-8") as f:
51
+ artifact = json.load(f)
52
+ except FileNotFoundError as e:
53
+ msg = f"Artifact file not found: {artifact_path}"
54
+ console.print("[red]✘ " + msg + "[/red]")
55
+ raise DbtArtifactValidationError(msg) from e
56
+ try:
57
+ parsed_artifact = parsing_func(artifact)
58
+ except ValidationError as e:
59
+ msg = f"Artifact validation failed for {artifact_path}: {e!r}"
60
+ console.print("[red]✘ " + msg + "[/red]")
61
+ raise DbtArtifactValidationError(msg) from e
62
+ parsed_artifacts[artifact_path] = parsed_artifact.model_dump(
63
+ by_alias=True, mode="json"
64
+ )
65
+ console.print(f"[green]✔ {artifact_path} validated and loaded.[/green]")
66
+
67
+ return parsed_artifacts
68
+
69
+
70
+ @app.callback(invoke_without_command=True)
71
+ def ingest(
72
+ ctx: Context, # noqa: ARG001
73
+ metadata_dir: Path = MetadataDir,
74
+ perspective_url: str = PerspectiveURL,
75
+ dry_run: bool = DryRun,
76
+ follow: bool = Follow,
77
+ is_test_results_ingestion: bool = IsDbtTestResultsIngestion,
78
+ ) -> None:
79
+ """Validate and ingest dbt artifacts from the specified directory.
80
+
81
+ By default, ingests `manifest.json` and `catalog.json`. If `--test-results` flag is
82
+ set, only ingests `run_results.json` instead.
83
+ """
84
+ # Validate and load artifacts
85
+ if is_test_results_ingestion:
86
+ artifacts = {str(metadata_dir / "run_results.json"): parse_run_results}
87
+ parsed_artifacts = _validate_artifacts(artifacts)
88
+ payload = parsed_artifacts[str(metadata_dir / "run_results.json")]
89
+ url = urljoin(perspective_url, "catalog/ingest/dbt/run_results/")
90
+ else:
91
+ artifacts = {
92
+ str(metadata_dir / "manifest.json"): parse_manifest,
93
+ str(metadata_dir / "catalog.json"): parse_catalog,
94
+ }
95
+ parsed_artifacts = _validate_artifacts(artifacts)
96
+ payload = {
97
+ "manifest_json": parsed_artifacts[str(metadata_dir / "manifest.json")],
98
+ "catalog_json": parsed_artifacts[str(metadata_dir / "catalog.json")],
99
+ }
100
+ url = urljoin(perspective_url, "catalog/ingest/dbt/")
101
+
102
+ # If in dry run mode, print the bundle and exit.
103
+ if dry_run:
104
+ console.print("Dry run mode: Payload with the following keys would be sent:")
105
+ console.print(list(payload.keys()))
106
+ raise Exit(0)
107
+
108
+ # Send ingestion request.
109
+ response, ingestion_uuid = send_request(
110
+ url=url,
111
+ method="POST",
112
+ payload=payload,
113
+ verify=False,
114
+ return_response_key="ingestion_uuid",
115
+ )
116
+
117
+ # Wait until ingestion is complete.
118
+ if follow and ingestion_uuid:
119
+ ingestion_status = None
120
+
121
+ with console.status("Waiting...", spinner="dots"):
122
+ for _ in range(30):
123
+ ingestion_status = check_ingestion_status(
124
+ perspective_url, ingestion_uuid, verify=False
125
+ )
126
+ if ingestion_status == IngestionStatus.successful.value:
127
+ response = check_ingestion_results(perspective_url, ingestion_uuid)
128
+ console.print()
129
+ console.print(f"Ingestion results for ID {ingestion_uuid}:")
130
+ console.print()
131
+ console.print(response)
132
+ return
133
+
134
+ if ingestion_status == IngestionStatus.failed.value:
135
+ console.print()
136
+ console.print(f"Ingestion failed for ID {ingestion_uuid}")
137
+ return
138
+
139
+ if ingestion_status == IngestionStatus.pending.value:
140
+ time.sleep(1)
141
+
142
+ if ingestion_status != IngestionStatus.successful.value:
143
+ console.print(
144
+ f"Ingestion did not complete successfully within the wait period. Status: {ingestion_status}"
145
+ )
146
+
147
+
148
+ # Run the application.
149
+ if __name__ == "__main__":
150
+ app()
@@ -0,0 +1,164 @@
1
+ """Ingest model metadata from various data sources."""
2
+
3
+ from importlib import import_module
4
+ import json
5
+ from time import sleep
6
+ from urllib.parse import urljoin
7
+
8
+ from loguru import logger
9
+ from typer import Exit, Typer
10
+
11
+ from perspective.ingest import dbt, postgres
12
+ from perspective.utils import (
13
+ IngestionStatus,
14
+ check_ingestion_results,
15
+ check_ingestion_status,
16
+ console,
17
+ send_request,
18
+ )
19
+ from perspective.utils.options import (
20
+ DryRun,
21
+ Follow,
22
+ FollowTimeout,
23
+ IngestionId,
24
+ PerspectiveURL,
25
+ )
26
+
27
+
28
+ app = Typer(name="ingest", no_args_is_help=True, pretty_exceptions_show_locals=False)
29
+
30
+
31
+ @app.command()
32
+ def status(
33
+ ingestion_id: IngestionId, perspective_url: str = PerspectiveURL
34
+ ) -> str | dict:
35
+ """Retrieve the status of an ingestion."""
36
+ return check_ingestion_status(perspective_url, ingestion_id)
37
+
38
+
39
+ # Add sub-apps for custom source implementations
40
+ app.add_typer(dbt.app, name="dbt", help="Ingest metadata from dbt.")
41
+ app.add_typer(postgres.app, name="postgres", help="Ingest metadata from Postgres.")
42
+
43
+
44
+ @app.command()
45
+ def powerbi(
46
+ perspective_url: str = PerspectiveURL,
47
+ dry_run: bool = DryRun,
48
+ follow: bool = Follow,
49
+ follow_timeout: int = FollowTimeout,
50
+ ) -> None:
51
+ """Ingest metadata from Power BI."""
52
+ return _ingest_generic_source(
53
+ "powerbi", perspective_url, dry_run, follow, follow_timeout
54
+ )
55
+
56
+
57
+ @app.command()
58
+ def qlik_sense(
59
+ perspective_url: str = PerspectiveURL,
60
+ dry_run: bool = DryRun,
61
+ follow: bool = Follow,
62
+ follow_timeout: int = FollowTimeout,
63
+ ) -> None:
64
+ """Ingest metadata from Qlik Sense."""
65
+ return _ingest_generic_source(
66
+ "qlik_sense", perspective_url, dry_run, follow, follow_timeout
67
+ )
68
+
69
+
70
+ @app.command()
71
+ def sap(
72
+ perspective_url: str = PerspectiveURL,
73
+ dry_run: bool = DryRun,
74
+ follow: bool = Follow,
75
+ follow_timeout: int = FollowTimeout,
76
+ ) -> None:
77
+ """Ingest metadata from SAP."""
78
+ return _ingest_generic_source(
79
+ "sap", perspective_url, dry_run, follow, follow_timeout
80
+ )
81
+
82
+
83
+ def _ingest_generic_source(
84
+ source_name: str,
85
+ perspective_url: str,
86
+ dry_run: bool,
87
+ follow: bool,
88
+ follow_timeout: int,
89
+ ) -> tuple[list, list]:
90
+ """Generic ingestion logic for standard sources."""
91
+ try:
92
+ module = import_module(
93
+ f"perspective.ingest.sources.database.{source_name}.pipeline"
94
+ )
95
+ source_type = "database"
96
+ except ModuleNotFoundError:
97
+ try:
98
+ module = import_module(
99
+ f"perspective.ingest.sources.bi.{source_name}.pipeline"
100
+ )
101
+ source_type = "dashboard"
102
+ except ModuleNotFoundError:
103
+ console.print(f"Unsupported metadata source: {source_name}")
104
+ raise
105
+
106
+ endpoint = urljoin(perspective_url, f"/ingest/{source_type}/")
107
+ manifests = module.pipeline()
108
+ responses = []
109
+ ingestion_ids = []
110
+ for manifest in manifests:
111
+ payload = json.loads(manifest.json(by_alias=True))
112
+ n_items = len(payload["payload"])
113
+
114
+ logger.debug(
115
+ f"Sending {n_items} items from {source_name} metadata source to Perspective Catalog at {perspective_url}..."
116
+ )
117
+
118
+ # If in dry run mode, print the bundle and exit.
119
+ if dry_run:
120
+ console.print(payload)
121
+ raise Exit(0)
122
+
123
+ # Send ingestion request.
124
+ response, ingestion_id = send_request(
125
+ url=endpoint,
126
+ payload=payload,
127
+ return_response_key="ingestion_id",
128
+ verify=False,
129
+ )
130
+
131
+ if follow and ingestion_id:
132
+ ingestion_status = None
133
+
134
+ with console.status("Waiting...", spinner="dots"):
135
+ for _ in range(follow_timeout):
136
+ ingestion_status = check_ingestion_status(
137
+ perspective_url, ingestion_id
138
+ )
139
+ if ingestion_status == IngestionStatus.successful.value:
140
+ response = check_ingestion_results(
141
+ perspective_url, ingestion_id
142
+ )
143
+ console.print()
144
+ console.print(f"Ingestion results for ID {ingestion_id}:")
145
+ console.print()
146
+ console.print(response)
147
+
148
+ if ingestion_status == IngestionStatus.failed.value:
149
+ console.print()
150
+ console.print(f"Ingestion failed for ID {ingestion_id}")
151
+
152
+ if ingestion_status == IngestionStatus.pending.value:
153
+ sleep(1)
154
+
155
+ if ingestion_status != IngestionStatus.successful.value:
156
+ status_human_readable = IngestionStatus(ingestion_status).name
157
+ console.print(
158
+ f"Ingestion did not complete successfully within {follow_timeout} seconds. Status: {status_human_readable}."
159
+ )
160
+
161
+ responses.append(response)
162
+ ingestion_ids.append(ingestion_id)
163
+
164
+ return responses, ingestion_ids