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.
- perspective_cli-0.1.0/PKG-INFO +49 -0
- perspective_cli-0.1.0/README.md +19 -0
- perspective_cli-0.1.0/pyproject.toml +167 -0
- perspective_cli-0.1.0/setup.cfg +4 -0
- perspective_cli-0.1.0/src/perspective/__init__.py +1 -0
- perspective_cli-0.1.0/src/perspective/config.py +240 -0
- perspective_cli-0.1.0/src/perspective/exceptions.py +15 -0
- perspective_cli-0.1.0/src/perspective/ingest/dbt.py +150 -0
- perspective_cli-0.1.0/src/perspective/ingest/ingest.py +164 -0
- perspective_cli-0.1.0/src/perspective/ingest/postgres.py +388 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/bi/powerbi/extract.py +184 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/bi/powerbi/models.py +137 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/bi/powerbi/pipeline.py +29 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/bi/powerbi/transform.py +478 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/bi/qlik_sense/extract.py +297 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/bi/qlik_sense/models.py +22 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/bi/qlik_sense/pipeline.py +19 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/bi/qlik_sense/transform.py +76 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/database/sap/extract.py +253 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/database/sap/pipeline.py +23 -0
- perspective_cli-0.1.0/src/perspective/ingest/sources/database/sap/transform.py +85 -0
- perspective_cli-0.1.0/src/perspective/main.py +74 -0
- perspective_cli-0.1.0/src/perspective/models/configs.py +422 -0
- perspective_cli-0.1.0/src/perspective/models/dashboards.py +44 -0
- perspective_cli-0.1.0/src/perspective/models/databases.py +26 -0
- perspective_cli-0.1.0/src/perspective/utils/__init__.py +3 -0
- perspective_cli-0.1.0/src/perspective/utils/options.py +77 -0
- perspective_cli-0.1.0/src/perspective/utils/utils.py +274 -0
- perspective_cli-0.1.0/src/perspective_cli.egg-info/PKG-INFO +49 -0
- perspective_cli-0.1.0/src/perspective_cli.egg-info/SOURCES.txt +32 -0
- perspective_cli-0.1.0/src/perspective_cli.egg-info/dependency_links.txt +1 -0
- perspective_cli-0.1.0/src/perspective_cli.egg-info/entry_points.txt +2 -0
- perspective_cli-0.1.0/src/perspective_cli.egg-info/requires.txt +17 -0
- 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 @@
|
|
|
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()
|