perspective-cli 0.1.0__tar.gz

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.
Files changed (34) hide show
  1. perspective_cli-0.1.0/PKG-INFO +49 -0
  2. perspective_cli-0.1.0/README.md +19 -0
  3. perspective_cli-0.1.0/pyproject.toml +167 -0
  4. perspective_cli-0.1.0/setup.cfg +4 -0
  5. perspective_cli-0.1.0/src/perspective/__init__.py +1 -0
  6. perspective_cli-0.1.0/src/perspective/config.py +240 -0
  7. perspective_cli-0.1.0/src/perspective/exceptions.py +15 -0
  8. perspective_cli-0.1.0/src/perspective/ingest/dbt.py +150 -0
  9. perspective_cli-0.1.0/src/perspective/ingest/ingest.py +164 -0
  10. perspective_cli-0.1.0/src/perspective/ingest/postgres.py +388 -0
  11. perspective_cli-0.1.0/src/perspective/ingest/sources/bi/powerbi/extract.py +184 -0
  12. perspective_cli-0.1.0/src/perspective/ingest/sources/bi/powerbi/models.py +137 -0
  13. perspective_cli-0.1.0/src/perspective/ingest/sources/bi/powerbi/pipeline.py +29 -0
  14. perspective_cli-0.1.0/src/perspective/ingest/sources/bi/powerbi/transform.py +478 -0
  15. perspective_cli-0.1.0/src/perspective/ingest/sources/bi/qlik_sense/extract.py +297 -0
  16. perspective_cli-0.1.0/src/perspective/ingest/sources/bi/qlik_sense/models.py +22 -0
  17. perspective_cli-0.1.0/src/perspective/ingest/sources/bi/qlik_sense/pipeline.py +19 -0
  18. perspective_cli-0.1.0/src/perspective/ingest/sources/bi/qlik_sense/transform.py +76 -0
  19. perspective_cli-0.1.0/src/perspective/ingest/sources/database/sap/extract.py +253 -0
  20. perspective_cli-0.1.0/src/perspective/ingest/sources/database/sap/pipeline.py +23 -0
  21. perspective_cli-0.1.0/src/perspective/ingest/sources/database/sap/transform.py +85 -0
  22. perspective_cli-0.1.0/src/perspective/main.py +74 -0
  23. perspective_cli-0.1.0/src/perspective/models/configs.py +422 -0
  24. perspective_cli-0.1.0/src/perspective/models/dashboards.py +44 -0
  25. perspective_cli-0.1.0/src/perspective/models/databases.py +26 -0
  26. perspective_cli-0.1.0/src/perspective/utils/__init__.py +3 -0
  27. perspective_cli-0.1.0/src/perspective/utils/options.py +77 -0
  28. perspective_cli-0.1.0/src/perspective/utils/utils.py +274 -0
  29. perspective_cli-0.1.0/src/perspective_cli.egg-info/PKG-INFO +49 -0
  30. perspective_cli-0.1.0/src/perspective_cli.egg-info/SOURCES.txt +32 -0
  31. perspective_cli-0.1.0/src/perspective_cli.egg-info/dependency_links.txt +1 -0
  32. perspective_cli-0.1.0/src/perspective_cli.egg-info/entry_points.txt +2 -0
  33. perspective_cli-0.1.0/src/perspective_cli.egg-info/requires.txt +17 -0
  34. perspective_cli-0.1.0/src/perspective_cli.egg-info/top_level.txt +1 -0
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: perspective-cli
3
+ Version: 0.1.0
4
+ Summary: A CLI tool for managing the Perspective AI platform.
5
+ Author-email: Michal Zawadzki <mzawadzki@dyvenia.com>
6
+ Keywords: cli,dbt,perspective,data,catalog,ai
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: typer[all]<1.0,>=0.9
15
+ Requires-Dist: psycopg[binary]>=3.2.3
16
+ Requires-Dist: requests<3.0,>=2.20
17
+ Requires-Dist: pyyaml>=6.0.1
18
+ Requires-Dist: rich<13.8,>=13.7
19
+ Requires-Dist: loguru>=0.7.2
20
+ Requires-Dist: websocket-client>=1.8.0
21
+ Requires-Dist: requests-ntlm>=1.3.0
22
+ Requires-Dist: websockets>=10.4
23
+ Requires-Dist: pydantic[email]==2.11.7
24
+ Requires-Dist: azure-identity>=1.17.1
25
+ Requires-Dist: dlt[duckdb]>=1.11.0
26
+ Requires-Dist: duckdb>1.1.3
27
+ Requires-Dist: dbt-artifacts-parser<1.0,>=0.12.0
28
+ Provides-Extra: sap
29
+ Requires-Dist: pyrfc==2.5.0; extra == "sap"
30
+
31
+ # perspective-cli
32
+
33
+ ## Installation
34
+
35
+ ### `pip`
36
+
37
+ ```bash
38
+ pip install perspective-cli
39
+ ```
40
+
41
+ ### `uv`
42
+
43
+ ```bash
44
+ uv add perspective-cli
45
+ ```
46
+
47
+ ## Next Steps
48
+
49
+ Proceed to the [official documentation](dev.meetperspective.com/docs/catalog/) for next steps and detailed guides on how to utilize `perspective-cli` effectively.
@@ -0,0 +1,19 @@
1
+ # perspective-cli
2
+
3
+ ## Installation
4
+
5
+ ### `pip`
6
+
7
+ ```bash
8
+ pip install perspective-cli
9
+ ```
10
+
11
+ ### `uv`
12
+
13
+ ```bash
14
+ uv add perspective-cli
15
+ ```
16
+
17
+ ## Next Steps
18
+
19
+ Proceed to the [official documentation](dev.meetperspective.com/docs/catalog/) for next steps and detailed guides on how to utilize `perspective-cli` effectively.
@@ -0,0 +1,167 @@
1
+ [project]
2
+ name = "perspective-cli"
3
+ description = "A CLI tool for managing the Perspective AI platform."
4
+ version = "0.1.0"
5
+ authors = [{ name = "Michal Zawadzki", email = "mzawadzki@dyvenia.com" }]
6
+ requires-python = ">=3.10"
7
+ readme = "README.md"
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Intended Audience :: Developers",
11
+ "Programming Language :: Python :: 3.10",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ ]
15
+ keywords = ["cli", "dbt", "perspective", "data", "catalog", "ai"]
16
+ dependencies = [
17
+ "typer[all] >=0.9,<1.0",
18
+ "psycopg[binary]>=3.2.3",
19
+ "requests >=2.20,<3.0",
20
+ "pyyaml >=6.0.1",
21
+ "rich >=13.7, <13.8",
22
+ "loguru>=0.7.2",
23
+ "websocket-client>=1.8.0",
24
+ "requests-ntlm>=1.3.0",
25
+ "websockets>=10.4",
26
+ "pydantic[email]==2.11.7",
27
+ "azure-identity>=1.17.1",
28
+ "dlt[duckdb]>=1.11.0",
29
+ "duckdb>1.1.3",
30
+ "dbt-artifacts-parser>=0.12.0,<1.0",
31
+ ]
32
+
33
+ [project.scripts]
34
+ perspective = "perspective.main:app"
35
+
36
+ [project.optional-dependencies]
37
+ # aka extras; eg. pip install perspective-cli[sap].
38
+ sap = ["pyrfc==2.5.0"]
39
+
40
+ [dependency-groups]
41
+ # Development groups; eg. uv sync --group dev.
42
+ dev = [
43
+ "pytest",
44
+ "pytest-cov",
45
+ "coverage",
46
+ "multiprocess >= 0.70.16,<0.71",
47
+ "uvicorn >=0.27.0.post1,<0.28",
48
+ "fastapi >=0.109.0, <0.110",
49
+ "pytest-postgresql>=6.1.1",
50
+ "ruff>=0.9.5",
51
+ "dbt-osmosis>=1.1.17",
52
+ "psycopg2>=2.9.10",
53
+ "responses>=0.25.8",
54
+ ]
55
+
56
+
57
+ [tool.uv]
58
+ package = true
59
+ no-build-isolation-package = ["pyrfc"]
60
+
61
+ [tool.ruff]
62
+ preview = true
63
+
64
+ [tool.ruff.lint]
65
+ pylint.max-args = 9
66
+
67
+ # Last rule review: ruff 0.1.5
68
+ extend-select = [
69
+ "I", # isort
70
+ "D", # pydocstyle
71
+ "W", # pycodestyle (warnings)
72
+ "B", # flake8-bugbear
73
+ "S", # flake8-bandit
74
+ "ANN", # flake8-annotations
75
+ "A", # flake8-builtins
76
+ "C4", # flake8-comprehensions
77
+ "EM", # flake8-errmsg
78
+ "T20", # flake8-print
79
+ "PT", # flake8-pytest-style
80
+ "RET", # flake8-return
81
+ "SIM", # flake8-simplify
82
+ "ARG", # flake8-unused-arguments
83
+ "PTH", # flake8-use-pathlib
84
+ "N", # pep8-naming
85
+ "UP", # pyupgrade
86
+ "C901", # mccabe
87
+ "FURB", # refurb
88
+ "TRY", # tryceratops
89
+ # "PD", # https://docs.astral.sh/ruff/rules/#pandas-vet-pd
90
+ "PL", # pylint
91
+ "RUF", # Ruff-specific rules
92
+ ]
93
+
94
+ ignore = [
95
+ "W191",
96
+ "D206",
97
+ "D300",
98
+ "ANN101", # Type annotation for `self`.
99
+ "ANN102", # Type annotation for `cls`.
100
+ "ANN204", # Return type annotation for `__init__`.
101
+ "D101", # Missing docstring in public class (we use __init__()).
102
+ ]
103
+
104
+ [tool.ruff.lint.extend-per-file-ignores]
105
+ "tests/**" = [
106
+ "S101",
107
+ "ANN201",
108
+ "ANN202",
109
+ "ANN001",
110
+ "D103",
111
+ "D100",
112
+ "N802",
113
+ "N803",
114
+ "B905",
115
+ "D102",
116
+ "PLR2004",
117
+ "PT004",
118
+ "ARG001",
119
+ "PLC2701", # Private function imports.
120
+ ]
121
+
122
+ [tool.ruff.lint.mccabe]
123
+ # Ignore rules known to be conflicting between the ruff linter and formatter.
124
+ # See https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
125
+ max-complexity = 10
126
+
127
+ [tool.ruff.lint.isort]
128
+ force-sort-within-sections = true
129
+ lines-after-imports = 2
130
+
131
+ [tool.ruff.lint.pydocstyle]
132
+ convention = "google"
133
+
134
+ [tool.ruff.lint.pycodestyle]
135
+ max-doc-length = 88
136
+
137
+ [tool.coverage.paths]
138
+ source = ["src", "*/site-packages"]
139
+ tests = ["tests", "*/tests"]
140
+
141
+ [tool.coverage.run]
142
+ branch = true
143
+ source = ["perspective", "tests"]
144
+
145
+ [tool.coverage.report]
146
+ show_missing = true
147
+ fail_under = 100
148
+
149
+ [tool.mypy]
150
+ strict = true
151
+ warn_unreachable = true
152
+ pretty = true
153
+ show_column_numbers = true
154
+ show_error_context = true
155
+
156
+ # For checking whether the docstrings match function signature.
157
+ # https://peps.python.org/pep-0727/ should basically solve this in Python 3.13.
158
+ [tool.pydoclint]
159
+ style = "google"
160
+ arg-type-hints-in-docstring = false
161
+ check-return-types = false
162
+ check-yield-types = false
163
+ allow-init-docstring = true
164
+
165
+ # [build-system]
166
+ # requires = ["hatchling"]
167
+ # build-backend = "hatchling.build"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Perspective CLI."""
@@ -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()