dbt-4ps-generator 0.1.1__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.
- dbt_4ps_generator-0.1.1/.gitignore +10 -0
- dbt_4ps_generator-0.1.1/.python-version +1 -0
- dbt_4ps_generator-0.1.1/LICENSE +21 -0
- dbt_4ps_generator-0.1.1/PKG-INFO +87 -0
- dbt_4ps_generator-0.1.1/README.md +59 -0
- dbt_4ps_generator-0.1.1/pyproject.toml +74 -0
- dbt_4ps_generator-0.1.1/src/dbt_4ps_generator/__init__.py +1 -0
- dbt_4ps_generator-0.1.1/src/dbt_4ps_generator/cdm.py +137 -0
- dbt_4ps_generator-0.1.1/src/dbt_4ps_generator/cli.py +109 -0
- dbt_4ps_generator-0.1.1/src/dbt_4ps_generator/converters.py +110 -0
- dbt_4ps_generator-0.1.1/src/dbt_4ps_generator/databricks_helper.py +25 -0
- dbt_4ps_generator-0.1.1/src/dbt_4ps_generator/dbt.py +253 -0
- dbt_4ps_generator-0.1.1/src/dbt_4ps_generator/sql.py +221 -0
- dbt_4ps_generator-0.1.1/tests/test_converters.py +133 -0
- dbt_4ps_generator-0.1.1/tests/test_generate.py +135 -0
- dbt_4ps_generator-0.1.1/uv.lock +1031 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Misja Pronk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dbt-4ps-generator
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Generate dbt staging models for 4PS Construct (Business Central) from bc2adls CDM manifests
|
|
5
|
+
Project-URL: Repository, https://github.com/misja-pronk/dbt-4ps-staging
|
|
6
|
+
Project-URL: Documentation, https://misja-pronk.github.io/dbt-4ps-staging/
|
|
7
|
+
Project-URL: Changelog, https://github.com/misja-pronk/dbt-4ps-staging/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Misja Pronk
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: 4ps,bc2adls,business-central,cdm,databricks,dbt
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Requires-Dist: databricks-sdk>=0.50.0
|
|
20
|
+
Requires-Dist: humps>=0.2.2
|
|
21
|
+
Requires-Dist: pydantic>=2.11.3
|
|
22
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Requires-Dist: shandy-sqlfmt[jinjafmt]>=0.26.0
|
|
25
|
+
Requires-Dist: sqlglot>=26.15.0
|
|
26
|
+
Requires-Dist: typer>=0.15.2
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# dbt-4ps-generator
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/dbt-4ps-generator/)
|
|
32
|
+
|
|
33
|
+
CLI that generates dbt staging models from a [bc2adls](https://github.com/Bertverbeek4PS/bc2adls) CDM export. For every entity in the manifest it writes:
|
|
34
|
+
|
|
35
|
+
- `stg_4ps__<table>.sql` — a `SELECT ... FROM STREAM READ_FILES(...)` model with the column types from the CDM metadata and columns renamed to snake_case (`CreditLimitLCY-20` → `credit_limit_lcy`)
|
|
36
|
+
- `_4ps__models.yaml` — model + column descriptions, and a `dbt_utils.unique_combination_of_columns` test on each table's primary key
|
|
37
|
+
|
|
38
|
+
Full documentation: <https://misja-pronk.github.io/dbt-4ps-staging/>
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uvx dbt-4ps-generator --help # run once, ephemerally
|
|
44
|
+
uv tool install dbt-4ps-generator # install the command on PATH
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or from a checkout: `uv run dbt-4ps-generator` in this directory.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
Download the CDM metadata from the Unity Catalog volume bc2adls exports to (authenticates via `DATABRICKS_HOST`/`DATABRICKS_TOKEN` or a `~/.databrickscfg` profile):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
dbt-4ps-generator download \
|
|
55
|
+
--volume-path /Volumes/<catalog>/<schema>/<volume> \
|
|
56
|
+
--output-directory ../_cdm
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Generate the models:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
dbt-4ps-generator generate \
|
|
63
|
+
--manifest ../_cdm/deltas.manifest.cdm.json \
|
|
64
|
+
--output-directory ../dbt_4ps_staging_package/models/staging/4ps
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Options (see `--help`): `--model-prefix` (default `stg_4ps__`), `--schema-prefix` (default `_4ps__`), and `--no-file-metadata-column` to omit the `_metadata` source-file column.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv sync
|
|
73
|
+
uv run pytest
|
|
74
|
+
uv run ruff check . && uv run ruff format --check .
|
|
75
|
+
uv run ty check
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Layout
|
|
79
|
+
|
|
80
|
+
| File (`src/dbt_4ps_generator/`) | Purpose |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `cli.py` | Typer CLI (`generate`, `download`) |
|
|
83
|
+
| `cdm.py` | Pydantic models for the CDM manifest and entity files |
|
|
84
|
+
| `converters.py` | CDM → dbt/SQL conversions (types, names, READ_FILES options) |
|
|
85
|
+
| `dbt.py` | Builders that write the .sql models and schema yaml |
|
|
86
|
+
| `sql.py` | SQL primitives (columns, options, streaming-table DDL builder) |
|
|
87
|
+
| `databricks_helper.py` | Volume download via the Databricks SDK |
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# dbt-4ps-generator
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/dbt-4ps-generator/)
|
|
4
|
+
|
|
5
|
+
CLI that generates dbt staging models from a [bc2adls](https://github.com/Bertverbeek4PS/bc2adls) CDM export. For every entity in the manifest it writes:
|
|
6
|
+
|
|
7
|
+
- `stg_4ps__<table>.sql` — a `SELECT ... FROM STREAM READ_FILES(...)` model with the column types from the CDM metadata and columns renamed to snake_case (`CreditLimitLCY-20` → `credit_limit_lcy`)
|
|
8
|
+
- `_4ps__models.yaml` — model + column descriptions, and a `dbt_utils.unique_combination_of_columns` test on each table's primary key
|
|
9
|
+
|
|
10
|
+
Full documentation: <https://misja-pronk.github.io/dbt-4ps-staging/>
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uvx dbt-4ps-generator --help # run once, ephemerally
|
|
16
|
+
uv tool install dbt-4ps-generator # install the command on PATH
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or from a checkout: `uv run dbt-4ps-generator` in this directory.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Download the CDM metadata from the Unity Catalog volume bc2adls exports to (authenticates via `DATABRICKS_HOST`/`DATABRICKS_TOKEN` or a `~/.databrickscfg` profile):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
dbt-4ps-generator download \
|
|
27
|
+
--volume-path /Volumes/<catalog>/<schema>/<volume> \
|
|
28
|
+
--output-directory ../_cdm
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Generate the models:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
dbt-4ps-generator generate \
|
|
35
|
+
--manifest ../_cdm/deltas.manifest.cdm.json \
|
|
36
|
+
--output-directory ../dbt_4ps_staging_package/models/staging/4ps
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Options (see `--help`): `--model-prefix` (default `stg_4ps__`), `--schema-prefix` (default `_4ps__`), and `--no-file-metadata-column` to omit the `_metadata` source-file column.
|
|
40
|
+
|
|
41
|
+
## Development
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv sync
|
|
45
|
+
uv run pytest
|
|
46
|
+
uv run ruff check . && uv run ruff format --check .
|
|
47
|
+
uv run ty check
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Layout
|
|
51
|
+
|
|
52
|
+
| File (`src/dbt_4ps_generator/`) | Purpose |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `cli.py` | Typer CLI (`generate`, `download`) |
|
|
55
|
+
| `cdm.py` | Pydantic models for the CDM manifest and entity files |
|
|
56
|
+
| `converters.py` | CDM → dbt/SQL conversions (types, names, READ_FILES options) |
|
|
57
|
+
| `dbt.py` | Builders that write the .sql models and schema yaml |
|
|
58
|
+
| `sql.py` | SQL primitives (columns, options, streaming-table DDL builder) |
|
|
59
|
+
| `databricks_helper.py` | Volume download via the Databricks SDK |
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dbt-4ps-generator"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Generate dbt staging models for 4PS Construct (Business Central) from bc2adls CDM manifests"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Misja Pronk" }]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
keywords = ["dbt", "databricks", "bc2adls", "business-central", "4ps", "cdm"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Topic :: Database",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"databricks-sdk>=0.50.0",
|
|
21
|
+
"humps>=0.2.2",
|
|
22
|
+
"pydantic>=2.11.3",
|
|
23
|
+
"pyyaml>=6.0.2",
|
|
24
|
+
"rich>=13.0.0",
|
|
25
|
+
"shandy-sqlfmt[jinjafmt]>=0.26.0",
|
|
26
|
+
"sqlglot>=26.15.0",
|
|
27
|
+
"typer>=0.15.2",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Repository = "https://github.com/misja-pronk/dbt-4ps-staging"
|
|
32
|
+
Documentation = "https://misja-pronk.github.io/dbt-4ps-staging/"
|
|
33
|
+
Changelog = "https://github.com/misja-pronk/dbt-4ps-staging/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
dbt-4ps-generator = "dbt_4ps_generator.cli:app"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/dbt_4ps_generator"]
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.0.0",
|
|
48
|
+
"ruff>=0.6",
|
|
49
|
+
"ty>=0.0.1a1",
|
|
50
|
+
]
|
|
51
|
+
docs = [
|
|
52
|
+
"mkdocs-material>=9.7.5",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
addopts = "-q"
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
target-version = "py313"
|
|
62
|
+
src = ["src", "tests"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.per-file-ignores]
|
|
68
|
+
# typer's idiom puts typer.Option(...) in argument defaults
|
|
69
|
+
"src/dbt_4ps_generator/cli.py" = ["B008"]
|
|
70
|
+
|
|
71
|
+
[tool.ty.environment]
|
|
72
|
+
python-version = "3.13"
|
|
73
|
+
root = ["src"]
|
|
74
|
+
extra-paths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generate dbt staging models for 4PS Construct from bc2adls CDM manifests."""
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Argument(BaseModel):
|
|
8
|
+
name: str
|
|
9
|
+
value: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PatternTraitReference(BaseModel):
|
|
13
|
+
trait_reference: str = Field(alias="traitReference")
|
|
14
|
+
arguments: list[Argument]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DataPartitionPattern(BaseModel):
|
|
18
|
+
name: str
|
|
19
|
+
root_location: str = Field(alias="rootLocation")
|
|
20
|
+
glob_pattern: str = Field(alias="globPattern")
|
|
21
|
+
exhibits_traits: list[PatternTraitReference] = Field(alias="exhibitsTraits")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ManifestEntity(BaseModel):
|
|
25
|
+
type: str
|
|
26
|
+
entity_name: str = Field(alias="entityName")
|
|
27
|
+
entity_path: str = Field(alias="entityPath")
|
|
28
|
+
data_partition_patterns: list[DataPartitionPattern] = Field(
|
|
29
|
+
default=[], alias="dataPartitionPatterns"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def get_entity_name(self) -> str:
|
|
33
|
+
return self.entity_name
|
|
34
|
+
|
|
35
|
+
def get_entity_path(self) -> str:
|
|
36
|
+
return self.entity_path
|
|
37
|
+
|
|
38
|
+
def get_root_location(self) -> str:
|
|
39
|
+
root_location = self.data_partition_patterns[0].root_location
|
|
40
|
+
return root_location
|
|
41
|
+
|
|
42
|
+
def get_glob_pattern(self) -> str:
|
|
43
|
+
glob_pattern = self.data_partition_patterns[0].glob_pattern
|
|
44
|
+
return glob_pattern
|
|
45
|
+
|
|
46
|
+
def get_trait_reference(self) -> str:
|
|
47
|
+
trait_reference = self.data_partition_patterns[0].exhibits_traits[0].trait_reference
|
|
48
|
+
return trait_reference
|
|
49
|
+
|
|
50
|
+
def get_arguments(self) -> list[Argument]:
|
|
51
|
+
arguments = self.data_partition_patterns[0].exhibits_traits[0].arguments
|
|
52
|
+
return arguments
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Manifest(BaseModel):
|
|
56
|
+
json_schema_semantic_version: str = Field(alias="jsonSchemaSemanticVersion")
|
|
57
|
+
imports: list[str]
|
|
58
|
+
manifest_name: str = Field(alias="manifestName")
|
|
59
|
+
explanation: str
|
|
60
|
+
entities: list[ManifestEntity] = Field(alias="entities")
|
|
61
|
+
relationships: list[str]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AppliedTrait(BaseModel):
|
|
65
|
+
trait_reference: str = Field(alias="traitReference")
|
|
66
|
+
arguments: list[dict]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Attribute(BaseModel):
|
|
70
|
+
name: str
|
|
71
|
+
data_format: str = Field(alias="dataFormat")
|
|
72
|
+
applied_traits: list[AppliedTrait] = Field(default=[], alias="appliedTraits")
|
|
73
|
+
display_name: str = Field(alias="displayName")
|
|
74
|
+
maximum_length: int | None = Field(alias="maximumLength")
|
|
75
|
+
is_primary_key: bool = Field(default=False, alias="isPrimaryKey")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Definition(BaseModel):
|
|
79
|
+
entity_name: str = Field(alias="entityName")
|
|
80
|
+
exhibits_traits: list[str] = Field(default=[], alias="exhibitsTraits")
|
|
81
|
+
display_name: str = Field(alias="displayName")
|
|
82
|
+
description: str
|
|
83
|
+
has_attributes: list[Attribute] = Field(alias="hasAttributes")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ModelEntity(BaseModel):
|
|
87
|
+
json_schema_semantic_version: str = Field(alias="jsonSchemaSemanticVersion")
|
|
88
|
+
imports: list[dict]
|
|
89
|
+
definitions: list[Definition]
|
|
90
|
+
|
|
91
|
+
def get_description(self) -> str:
|
|
92
|
+
description = self.definitions[0].description
|
|
93
|
+
return description
|
|
94
|
+
|
|
95
|
+
def get_attributes(self) -> list[Attribute]:
|
|
96
|
+
attributes = self.definitions[0].has_attributes
|
|
97
|
+
return attributes
|
|
98
|
+
|
|
99
|
+
def get_display_name(self) -> str:
|
|
100
|
+
display_name = self.definitions[0].display_name
|
|
101
|
+
return display_name
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Entity(BaseModel):
|
|
105
|
+
root_location: str
|
|
106
|
+
glob_pattern: str
|
|
107
|
+
description: str
|
|
108
|
+
attributes: list[Attribute]
|
|
109
|
+
entity_name: str
|
|
110
|
+
entity_path: str
|
|
111
|
+
trait_reference: str
|
|
112
|
+
arguments: list[Argument]
|
|
113
|
+
display_name: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_entities(manifest_path: Path) -> list[Entity]:
|
|
117
|
+
entities = []
|
|
118
|
+
manifest = Manifest(**json.loads(manifest_path.read_text()))
|
|
119
|
+
for manifest_entity in manifest.entities:
|
|
120
|
+
model_entity_path = manifest_path.parent / Path(manifest_entity.entity_path.split("/")[0])
|
|
121
|
+
model_entity = ModelEntity(**json.loads(model_entity_path.read_text()))
|
|
122
|
+
|
|
123
|
+
entity = Entity(
|
|
124
|
+
trait_reference=manifest_entity.get_trait_reference(),
|
|
125
|
+
root_location=manifest_entity.get_root_location(),
|
|
126
|
+
description=model_entity.get_description(),
|
|
127
|
+
glob_pattern=manifest_entity.get_glob_pattern(),
|
|
128
|
+
entity_name=manifest_entity.get_entity_name(),
|
|
129
|
+
display_name=model_entity.get_display_name(),
|
|
130
|
+
entity_path=manifest_entity.get_entity_path(),
|
|
131
|
+
arguments=manifest_entity.get_arguments(),
|
|
132
|
+
attributes=model_entity.get_attributes(),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
entities.append(entity)
|
|
136
|
+
|
|
137
|
+
return entities
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from importlib.metadata import version as pkg_version
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from dbt_4ps_generator.cdm import build_entities
|
|
7
|
+
from dbt_4ps_generator.converters import (
|
|
8
|
+
argument_to_option,
|
|
9
|
+
attribute_to_column,
|
|
10
|
+
construct_path,
|
|
11
|
+
to_snake_case_with_column_number_removed,
|
|
12
|
+
trait_reference_to_format,
|
|
13
|
+
)
|
|
14
|
+
from dbt_4ps_generator.databricks_helper import download_cdm_files_from_databricks_volume
|
|
15
|
+
from dbt_4ps_generator.dbt import ModelBuilder, ModelsSchemaBuilder
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="Generate dbt staging models from bc2adls CDM manifests.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _version_callback(value: bool):
|
|
21
|
+
if value:
|
|
22
|
+
typer.echo(pkg_version("dbt-4ps-generator"))
|
|
23
|
+
raise typer.Exit()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback()
|
|
27
|
+
def main(
|
|
28
|
+
version: bool = typer.Option(
|
|
29
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show the version."
|
|
30
|
+
),
|
|
31
|
+
):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def generate(
|
|
37
|
+
manifest: Path = typer.Option(
|
|
38
|
+
...,
|
|
39
|
+
help="Path to the CDM manifest, e.g. _cdm/deltas.manifest.cdm.json. "
|
|
40
|
+
"Entity .cdm.json files are resolved relative to this file.",
|
|
41
|
+
exists=True,
|
|
42
|
+
dir_okay=False,
|
|
43
|
+
),
|
|
44
|
+
output_directory: Path = typer.Option(
|
|
45
|
+
...,
|
|
46
|
+
help="Directory the .sql models and schema yaml are written to, "
|
|
47
|
+
"e.g. dbt_4ps_staging_package/models/staging/4ps.",
|
|
48
|
+
file_okay=False,
|
|
49
|
+
),
|
|
50
|
+
model_prefix: str = typer.Option("stg_4ps__", help="Prefix for generated model names."),
|
|
51
|
+
schema_name: str = typer.Option("models", help="Base name of the generated schema yaml."),
|
|
52
|
+
schema_prefix: str = typer.Option("_4ps__", help="Prefix for the generated schema yaml."),
|
|
53
|
+
file_metadata_column: bool = typer.Option(
|
|
54
|
+
True, help="Add the _metadata column (source file info) to every model."
|
|
55
|
+
),
|
|
56
|
+
):
|
|
57
|
+
"""Generate one staging model per entity in the manifest, plus a schema yaml."""
|
|
58
|
+
output_directory.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
entities = build_entities(manifest)
|
|
61
|
+
configuration_builder = (
|
|
62
|
+
ModelsSchemaBuilder()
|
|
63
|
+
.name(name=schema_name, prefix=schema_prefix)
|
|
64
|
+
.output_directory(output_directory)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
for entity in entities:
|
|
68
|
+
model_builder = (
|
|
69
|
+
ModelBuilder()
|
|
70
|
+
.name(name=entity.entity_name, prefix=model_prefix)
|
|
71
|
+
.description(entity.description)
|
|
72
|
+
.add_rename_function(to_snake_case_with_column_number_removed)
|
|
73
|
+
.add_options([argument_to_option(a) for a in entity.arguments])
|
|
74
|
+
.format(trait_reference_to_format(entity.trait_reference))
|
|
75
|
+
.add_columns([attribute_to_column(a) for a in entity.attributes])
|
|
76
|
+
.path(
|
|
77
|
+
construct_path(root_location=entity.root_location, glob_pattern=entity.glob_pattern)
|
|
78
|
+
)
|
|
79
|
+
.add_file_metadata_column(file_metadata_column)
|
|
80
|
+
.output_directory(output_directory)
|
|
81
|
+
.build()
|
|
82
|
+
)
|
|
83
|
+
configuration_builder.add_model_builder(model_builder)
|
|
84
|
+
|
|
85
|
+
configuration_builder.build()
|
|
86
|
+
typer.echo(f"Generated {len(entities)} models in {output_directory}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def download(
|
|
91
|
+
volume_path: str = typer.Option(
|
|
92
|
+
...,
|
|
93
|
+
help="Unity Catalog volume path holding the bc2adls export, "
|
|
94
|
+
"e.g. /Volumes/<catalog>/<schema>/<volume>.",
|
|
95
|
+
),
|
|
96
|
+
output_directory: Path = typer.Option(
|
|
97
|
+
..., help="Local directory to download the CDM json files into.", file_okay=False
|
|
98
|
+
),
|
|
99
|
+
):
|
|
100
|
+
"""Download CDM manifest and entity files from a Databricks volume.
|
|
101
|
+
|
|
102
|
+
Authenticates via the Databricks SDK's default chain: set DATABRICKS_HOST and
|
|
103
|
+
DATABRICKS_TOKEN, or use a configured profile (DATABRICKS_CONFIG_PROFILE).
|
|
104
|
+
"""
|
|
105
|
+
download_cdm_files_from_databricks_volume(volume_path, output_directory)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
app()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from dbt_4ps_generator.cdm import Argument, Attribute
|
|
4
|
+
from dbt_4ps_generator.sql import Column, Option
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def to_snake_case_with_column_number_removed(input_string):
|
|
8
|
+
# Remove trailing hyphen + digits first
|
|
9
|
+
input_string = re.sub(r"[-_]\d+$", "", input_string)
|
|
10
|
+
|
|
11
|
+
# Find all words correctly
|
|
12
|
+
words = re.findall(
|
|
13
|
+
r"(?:[A-Z]+(?=[A-Z][a-z]))|(?:[A-Z]?[a-z]+)|(?:[A-Z]+)|(?:\d+)", input_string
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Join with underscores and lowercase
|
|
17
|
+
to_snake_case = "_".join(map(str.lower, words))
|
|
18
|
+
return to_snake_case
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def trait_reference_to_format(trait_reference: str):
|
|
22
|
+
return trait_reference.split(".")[-1].lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def argument_to_option(argument: Argument) -> Option:
|
|
26
|
+
match argument.name:
|
|
27
|
+
case "columnHeaders":
|
|
28
|
+
return Option(key="header", value=argument.value)
|
|
29
|
+
|
|
30
|
+
case "delimiter":
|
|
31
|
+
return Option(key="sep", value=argument.value)
|
|
32
|
+
|
|
33
|
+
case "encoding":
|
|
34
|
+
return Option(key="encoding", value=argument.value.upper())
|
|
35
|
+
|
|
36
|
+
case "escape":
|
|
37
|
+
# double backslashes so the value survives SQL string-literal parsing
|
|
38
|
+
return Option(key="escape", value=argument.value.replace("\\", "\\\\"))
|
|
39
|
+
case _:
|
|
40
|
+
return Option(key=argument.name, value=argument.value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def attribute_to_column(attribute: Attribute) -> Column:
|
|
44
|
+
match attribute.data_format.lower():
|
|
45
|
+
case "string":
|
|
46
|
+
type = "string"
|
|
47
|
+
case "int32":
|
|
48
|
+
type = "int"
|
|
49
|
+
case "decimal":
|
|
50
|
+
precision = None
|
|
51
|
+
scale = None
|
|
52
|
+
|
|
53
|
+
if attribute.maximum_length:
|
|
54
|
+
precision = attribute.maximum_length
|
|
55
|
+
|
|
56
|
+
for applied_trait in attribute.applied_traits:
|
|
57
|
+
if applied_trait.trait_reference == "is.dataFormat.numeric.shaped":
|
|
58
|
+
for argument in applied_trait.arguments:
|
|
59
|
+
if argument["name"] == "scale":
|
|
60
|
+
scale = argument["value"]
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
if precision and scale:
|
|
64
|
+
type = f"decimal({precision},{scale})"
|
|
65
|
+
elif precision:
|
|
66
|
+
type = f"decimal({precision})"
|
|
67
|
+
else:
|
|
68
|
+
type = "string"
|
|
69
|
+
|
|
70
|
+
case "double":
|
|
71
|
+
type = "double"
|
|
72
|
+
case "option":
|
|
73
|
+
type = "string"
|
|
74
|
+
case "int64":
|
|
75
|
+
type = "bigint"
|
|
76
|
+
case "boolean":
|
|
77
|
+
type = "boolean"
|
|
78
|
+
case "integer" | "interger": # tolerate the misspelled variant seen in some exports
|
|
79
|
+
type = "int"
|
|
80
|
+
case "time":
|
|
81
|
+
type = "string"
|
|
82
|
+
case "code":
|
|
83
|
+
type = "string"
|
|
84
|
+
case "date":
|
|
85
|
+
type = "date"
|
|
86
|
+
case "datetime":
|
|
87
|
+
type = "timestamp"
|
|
88
|
+
case "datetimeoffset":
|
|
89
|
+
type = "timestamp"
|
|
90
|
+
case "duration":
|
|
91
|
+
type = "string"
|
|
92
|
+
case "guid":
|
|
93
|
+
type = "string"
|
|
94
|
+
case _:
|
|
95
|
+
type = "string"
|
|
96
|
+
|
|
97
|
+
return Column(
|
|
98
|
+
name=attribute.name,
|
|
99
|
+
type=type,
|
|
100
|
+
display_name=attribute.display_name,
|
|
101
|
+
is_primary_key=attribute.is_primary_key,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def construct_path(root_location: str, glob_pattern: str) -> str:
|
|
106
|
+
volume = (
|
|
107
|
+
r'/Volumes/{{ var("source")["database"] }}'
|
|
108
|
+
r'/{{ var("source")["schema"] }}/{{ var("source")["volume"] }}'
|
|
109
|
+
)
|
|
110
|
+
return f"{volume}/{root_location}{glob_pattern}"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from databricks.sdk import WorkspaceClient
|
|
4
|
+
from rich import print
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def download_cdm_files_from_databricks_volume(volume_path: str, download_folder: Path) -> None:
|
|
8
|
+
"""Download all .json files from a Unity Catalog volume to a local folder.
|
|
9
|
+
|
|
10
|
+
Uses the Databricks SDK default authentication chain (DATABRICKS_HOST /
|
|
11
|
+
DATABRICKS_TOKEN environment variables, or a profile from ~/.databrickscfg).
|
|
12
|
+
"""
|
|
13
|
+
w = WorkspaceClient()
|
|
14
|
+
download_folder.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
for directory_entry in w.files.list_directory_contents(volume_path):
|
|
17
|
+
name, path = directory_entry.name, directory_entry.path
|
|
18
|
+
if directory_entry.is_directory or not name or not path or not name.endswith(".json"):
|
|
19
|
+
continue
|
|
20
|
+
print(path)
|
|
21
|
+
resp = w.files.download(path)
|
|
22
|
+
if resp.contents is None:
|
|
23
|
+
raise RuntimeError(f"Empty response downloading {path}")
|
|
24
|
+
download_file = download_folder / Path(name)
|
|
25
|
+
download_file.write_text(data=str(resp.contents.read(), encoding="utf-8"))
|