dbt-ml 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.
- dbt_ml/__init__.py +1 -0
- dbt_ml/adapters/__init__.py +12 -0
- dbt_ml/adapters/base.py +142 -0
- dbt_ml/adapters/duckdb.py +202 -0
- dbt_ml/adapters/registry.py +34 -0
- dbt_ml/backends/__init__.py +19 -0
- dbt_ml/backends/base.py +35 -0
- dbt_ml/backends/email_backend.py +93 -0
- dbt_ml/backends/html_backend.py +80 -0
- dbt_ml/backends/json_backend.py +40 -0
- dbt_ml/backends/llm_backend.py +238 -0
- dbt_ml/backends/markdown_backend.py +78 -0
- dbt_ml/backends/pdf_backend.py +63 -0
- dbt_ml/backends/registry.py +30 -0
- dbt_ml/checks/__init__.py +4 -0
- dbt_ml/checks/python.py +63 -0
- dbt_ml/checks/runner.py +68 -0
- dbt_ml/checks/schema.py +214 -0
- dbt_ml/cli.py +671 -0
- dbt_ml/config/__init__.py +19 -0
- dbt_ml/config/loader.py +54 -0
- dbt_ml/config/model.py +41 -0
- dbt_ml/config/profile.py +42 -0
- dbt_ml/config/project.py +37 -0
- dbt_ml/config/source.py +34 -0
- dbt_ml/dag.py +181 -0
- dbt_ml/dbt_export.py +171 -0
- dbt_ml/docs.py +120 -0
- dbt_ml/freshness.py +103 -0
- dbt_ml/manifest.py +132 -0
- dbt_ml/profile.py +179 -0
- dbt_ml/runner.py +341 -0
- dbt_ml/synth/__init__.py +17 -0
- dbt_ml/synth/invoice_pdfs.py +107 -0
- dbt_ml/synth/invoice_text.py +64 -0
- dbt_ml/synth/invoices.py +50 -0
- dbt_ml/synth/posts.py +55 -0
- dbt_ml/synth/product_html.py +78 -0
- dbt_ml/synth/support_emails.py +50 -0
- dbt_ml/synth/support_tickets.py +81 -0
- dbt_ml/templates/docs/base.html +52 -0
- dbt_ml/templates/docs/index.html +54 -0
- dbt_ml/templates/docs/lineage.html +29 -0
- dbt_ml/templates/docs/model.html +67 -0
- dbt_ml/templates/html/dbt_ml_project.yml +11 -0
- dbt_ml/templates/html/models/raw_pages.yml +28 -0
- dbt_ml/templates/html/profiles.yml +8 -0
- dbt_ml/templates/html/sources/pages.yml +8 -0
- dbt_ml/templates/html/transforms/.gitkeep +0 -0
- dbt_ml/templates/json/.gitkeep +0 -0
- dbt_ml/templates/json/dbt_ml_project.yml +11 -0
- dbt_ml/templates/json/models/raw_invoices.yml +25 -0
- dbt_ml/templates/json/profiles.yml +8 -0
- dbt_ml/templates/json/sources/invoices.yml +8 -0
- dbt_ml/templates/json/transforms/.gitkeep +0 -0
- dbt_ml/templates/markdown/dbt_ml_project.yml +11 -0
- dbt_ml/templates/markdown/models/raw_documents.yml +24 -0
- dbt_ml/templates/markdown/profiles.yml +8 -0
- dbt_ml/templates/markdown/sources/documents.yml +8 -0
- dbt_ml/templates/markdown/transforms/.gitkeep +0 -0
- dbt_ml/templates/pdf/dbt_ml_project.yml +11 -0
- dbt_ml/templates/pdf/models/raw_pdf_text.yml +24 -0
- dbt_ml/templates/pdf/profiles.yml +13 -0
- dbt_ml/templates/pdf/sources/documents.yml +11 -0
- dbt_ml/templates/pdf/transforms/.gitkeep +0 -0
- dbt_ml/text/__init__.py +33 -0
- dbt_ml/text/dedup.py +68 -0
- dbt_ml/text/encoding.py +16 -0
- dbt_ml/text/language.py +21 -0
- dbt_ml/text/pii.py +173 -0
- dbt_ml/text/stats.py +35 -0
- dbt_ml/text/tokens.py +49 -0
- dbt_ml/text/transforms/__init__.py +12 -0
- dbt_ml/text/transforms/_helpers.py +23 -0
- dbt_ml/text/transforms/clean_encoding.py +28 -0
- dbt_ml/text/transforms/count_tokens.py +30 -0
- dbt_ml/text/transforms/detect_language.py +30 -0
- dbt_ml/text/transforms/find_duplicates.py +43 -0
- dbt_ml/text/transforms/redact_pii.py +64 -0
- dbt_ml/text/transforms/text_stats.py +36 -0
- dbt_ml/transforms/__init__.py +3 -0
- dbt_ml/transforms/runner.py +69 -0
- dbt_ml/versioning.py +46 -0
- dbt_ml-0.1.0.dist-info/METADATA +346 -0
- dbt_ml-0.1.0.dist-info/RECORD +88 -0
- dbt_ml-0.1.0.dist-info/WHEEL +4 -0
- dbt_ml-0.1.0.dist-info/entry_points.txt +2 -0
- dbt_ml-0.1.0.dist-info/licenses/LICENSE +21 -0
dbt_ml/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from . import duckdb # noqa: F401 # side-effect: registers DuckDBAdapter
|
|
2
|
+
from .base import AdapterError, WarehouseAdapter
|
|
3
|
+
from .registry import UnknownAdapterError, create_adapter, list_adapter_types, register
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AdapterError",
|
|
7
|
+
"UnknownAdapterError",
|
|
8
|
+
"WarehouseAdapter",
|
|
9
|
+
"create_adapter",
|
|
10
|
+
"list_adapter_types",
|
|
11
|
+
"register",
|
|
12
|
+
]
|
dbt_ml/adapters/base.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Warehouse adapter base class.
|
|
2
|
+
|
|
3
|
+
Each adapter wraps a warehouse-specific connection and exposes a uniform
|
|
4
|
+
interface for the runner: connect/close, schema management, materialization
|
|
5
|
+
(full + incremental), querying, and incremental-state CRUD. The point is
|
|
6
|
+
that runner.py / manifest.py / dbt_export.py / cli.py never speak DuckDB
|
|
7
|
+
SQL directly — they call adapter methods.
|
|
8
|
+
|
|
9
|
+
Today: DuckDB. v0.2.2: LanceDB. Beyond: Postgres / Snowflake / BigQuery /
|
|
10
|
+
Databricks / Redshift, matching the dbt-core set.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import Any, Self
|
|
18
|
+
|
|
19
|
+
import polars as pl
|
|
20
|
+
|
|
21
|
+
from ..config.profile import WarehouseConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AdapterError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WarehouseAdapter(ABC):
|
|
29
|
+
"""Lifecycle-managed warehouse driver."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: WarehouseConfig, *, project_dir: Path | None = None) -> None:
|
|
32
|
+
self.config = config
|
|
33
|
+
self.project_dir = project_dir
|
|
34
|
+
|
|
35
|
+
# ─── classification ────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def adapter_type(cls) -> str:
|
|
40
|
+
"""Short name used in profiles.yml `warehouse.type`."""
|
|
41
|
+
|
|
42
|
+
# ─── lifecycle ────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> Self:
|
|
45
|
+
self._connect()
|
|
46
|
+
self._ensure_schema()
|
|
47
|
+
self._ensure_state_table()
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(
|
|
51
|
+
self,
|
|
52
|
+
exc_type: type[BaseException] | None,
|
|
53
|
+
exc: BaseException | None,
|
|
54
|
+
tb: TracebackType | None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._close()
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def _connect(self) -> None: ...
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def _close(self) -> None: ...
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def _ensure_schema(self) -> None: ...
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def _ensure_state_table(self) -> None: ...
|
|
69
|
+
|
|
70
|
+
# ─── identity / SQL references ────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def catalog(self) -> str:
|
|
75
|
+
"""Catalog name used in SQL references and emitted dbt sources."""
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def schema(self) -> str:
|
|
79
|
+
return self.config.schema_name
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def schema_ref(self) -> str:
|
|
84
|
+
"""Quoted, fully-qualified schema reference for use in SQL."""
|
|
85
|
+
|
|
86
|
+
def table_ref(self, table: str) -> str:
|
|
87
|
+
return f"{self.schema_ref}.{table}"
|
|
88
|
+
|
|
89
|
+
# ─── materialization ──────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def materialize_full(self, table: str, df: pl.DataFrame) -> int:
|
|
93
|
+
"""Replace `table` with `df`. Returns row count written."""
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def materialize_incremental(
|
|
97
|
+
self, table: str, df: pl.DataFrame, *, key_col: str
|
|
98
|
+
) -> int:
|
|
99
|
+
"""Upsert rows in `df` into `table`, keyed on `key_col`. Returns rows written."""
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def drop_table(self, table: str) -> None: ...
|
|
103
|
+
|
|
104
|
+
# ─── querying ─────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def execute(self, sql: str, params: list[Any] | None = None) -> Any: ...
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def query_df(self, sql: str) -> pl.DataFrame: ...
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def scalar(self, sql: str, params: list[Any] | None = None) -> Any:
|
|
114
|
+
"""First column of first row, or None."""
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def rows(self, sql: str, params: list[Any] | None = None) -> list[tuple]: ...
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
def list_tables(self) -> list[str]: ...
|
|
121
|
+
|
|
122
|
+
# ─── lifecycle-bypass operations ──────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
def clean(self) -> str:
|
|
126
|
+
"""Remove everything this adapter has materialized. Returns a human-readable
|
|
127
|
+
description of what was removed. Implementations handle their own short-lived
|
|
128
|
+
connection if needed — does not require __enter__ to be called first."""
|
|
129
|
+
|
|
130
|
+
# ─── incremental state CRUD ───────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
def fetch_state(self, model_name: str) -> dict[str, tuple[str, str]]:
|
|
134
|
+
"""Return {document_id: (content_hash, code_version)} for `model_name`."""
|
|
135
|
+
|
|
136
|
+
@abstractmethod
|
|
137
|
+
def upsert_state(
|
|
138
|
+
self, model_name: str, records: list[tuple[str, str, str]]
|
|
139
|
+
) -> None: ...
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def clear_model_state(self, model_name: str) -> None: ...
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import duckdb
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
from ..config.profile import WarehouseConfig
|
|
10
|
+
from .base import AdapterError, WarehouseAdapter
|
|
11
|
+
from .registry import register
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register
|
|
15
|
+
class DuckDBAdapter(WarehouseAdapter):
|
|
16
|
+
"""The reference implementation. Wraps a single DuckDB connection.
|
|
17
|
+
|
|
18
|
+
DuckDB-specific wrinkle: the catalog name comes from the database
|
|
19
|
+
filename's stem; if the schema and the catalog collide (both `dbt_ml`)
|
|
20
|
+
we have to fully-qualify SQL references as `"catalog"."schema"`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: WarehouseConfig, *, project_dir: Path | None = None) -> None:
|
|
24
|
+
super().__init__(config, project_dir=project_dir)
|
|
25
|
+
self._con: duckdb.DuckDBPyConnection | None = None
|
|
26
|
+
self._catalog: str = ""
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def adapter_type(cls) -> str:
|
|
30
|
+
return "duckdb"
|
|
31
|
+
|
|
32
|
+
# ─── lifecycle ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def _connect(self) -> None:
|
|
35
|
+
db_path = self._resolved_path()
|
|
36
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self._con = duckdb.connect(str(db_path))
|
|
38
|
+
row = self._con.execute("SELECT current_database()").fetchone()
|
|
39
|
+
self._catalog = row[0] if row else "memory"
|
|
40
|
+
|
|
41
|
+
def _close(self) -> None:
|
|
42
|
+
if self._con is not None:
|
|
43
|
+
self._con.close()
|
|
44
|
+
self._con = None
|
|
45
|
+
|
|
46
|
+
def _ensure_schema(self) -> None:
|
|
47
|
+
self.connection.execute(f"CREATE SCHEMA IF NOT EXISTS {self.schema_ref}")
|
|
48
|
+
|
|
49
|
+
def _ensure_state_table(self) -> None:
|
|
50
|
+
self.connection.execute(
|
|
51
|
+
f"""
|
|
52
|
+
CREATE TABLE IF NOT EXISTS {self.schema_ref}.dbt_ml_state (
|
|
53
|
+
model_name VARCHAR NOT NULL,
|
|
54
|
+
document_id VARCHAR NOT NULL,
|
|
55
|
+
content_hash VARCHAR NOT NULL,
|
|
56
|
+
code_version VARCHAR NOT NULL,
|
|
57
|
+
last_run_at TIMESTAMP NOT NULL,
|
|
58
|
+
PRIMARY KEY (model_name, document_id)
|
|
59
|
+
)
|
|
60
|
+
"""
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# ─── identity ────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def connection(self) -> duckdb.DuckDBPyConnection:
|
|
67
|
+
if self._con is None:
|
|
68
|
+
raise AdapterError("Adapter must be used as a context manager")
|
|
69
|
+
return self._con
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def raw_connection(self) -> duckdb.DuckDBPyConnection:
|
|
73
|
+
"""The underlying warehouse driver. Handed to custom python tests."""
|
|
74
|
+
return self.connection
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def catalog(self) -> str:
|
|
78
|
+
if not self._catalog:
|
|
79
|
+
raise AdapterError("Adapter must be used as a context manager")
|
|
80
|
+
return self._catalog
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def schema_ref(self) -> str:
|
|
84
|
+
return f'"{self.catalog}"."{self.schema}"'
|
|
85
|
+
|
|
86
|
+
# ─── materialization ─────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def materialize_full(self, table: str, df: pl.DataFrame) -> int:
|
|
89
|
+
full = self.table_ref(table)
|
|
90
|
+
self.connection.register("dbt_ml_staging", df)
|
|
91
|
+
try:
|
|
92
|
+
self.connection.execute(
|
|
93
|
+
f"CREATE OR REPLACE TABLE {full} AS SELECT * FROM dbt_ml_staging"
|
|
94
|
+
)
|
|
95
|
+
finally:
|
|
96
|
+
self.connection.unregister("dbt_ml_staging")
|
|
97
|
+
return df.height
|
|
98
|
+
|
|
99
|
+
def materialize_incremental(
|
|
100
|
+
self, table: str, df: pl.DataFrame, *, key_col: str
|
|
101
|
+
) -> int:
|
|
102
|
+
if df.height == 0:
|
|
103
|
+
return 0
|
|
104
|
+
full = self.table_ref(table)
|
|
105
|
+
self.connection.register("dbt_ml_staging", df)
|
|
106
|
+
try:
|
|
107
|
+
self.connection.execute(
|
|
108
|
+
f"CREATE TABLE IF NOT EXISTS {full} AS "
|
|
109
|
+
f"SELECT * FROM dbt_ml_staging LIMIT 0"
|
|
110
|
+
)
|
|
111
|
+
if key_col in df.columns:
|
|
112
|
+
ids = df[key_col].to_list()
|
|
113
|
+
if ids:
|
|
114
|
+
placeholders = ",".join(["?"] * len(ids))
|
|
115
|
+
self.connection.execute(
|
|
116
|
+
f'DELETE FROM {full} WHERE "{key_col}" IN ({placeholders})',
|
|
117
|
+
ids,
|
|
118
|
+
)
|
|
119
|
+
self.connection.execute(f"INSERT INTO {full} SELECT * FROM dbt_ml_staging")
|
|
120
|
+
finally:
|
|
121
|
+
self.connection.unregister("dbt_ml_staging")
|
|
122
|
+
return df.height
|
|
123
|
+
|
|
124
|
+
def drop_table(self, table: str) -> None:
|
|
125
|
+
self.connection.execute(f"DROP TABLE IF EXISTS {self.table_ref(table)}")
|
|
126
|
+
|
|
127
|
+
# ─── querying ────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def execute(self, sql: str, params: list[Any] | None = None) -> Any:
|
|
130
|
+
if params is None:
|
|
131
|
+
return self.connection.execute(sql)
|
|
132
|
+
return self.connection.execute(sql, params)
|
|
133
|
+
|
|
134
|
+
def query_df(self, sql: str) -> pl.DataFrame:
|
|
135
|
+
return self.connection.execute(sql).pl()
|
|
136
|
+
|
|
137
|
+
def scalar(self, sql: str, params: list[Any] | None = None) -> Any:
|
|
138
|
+
row = self.execute(sql, params).fetchone()
|
|
139
|
+
return row[0] if row else None
|
|
140
|
+
|
|
141
|
+
def rows(self, sql: str, params: list[Any] | None = None) -> list[tuple]:
|
|
142
|
+
return self.execute(sql, params).fetchall()
|
|
143
|
+
|
|
144
|
+
def clean(self) -> str:
|
|
145
|
+
"""Delete the DuckDB file. Closes the connection if open."""
|
|
146
|
+
if self._con is not None:
|
|
147
|
+
self._close()
|
|
148
|
+
path = self._resolved_path()
|
|
149
|
+
if path.exists():
|
|
150
|
+
path.unlink()
|
|
151
|
+
return str(path)
|
|
152
|
+
|
|
153
|
+
def list_tables(self) -> list[str]:
|
|
154
|
+
rows = self.connection.execute(
|
|
155
|
+
"SELECT table_name FROM information_schema.tables "
|
|
156
|
+
"WHERE table_catalog = ? AND table_schema = ? AND table_name != 'dbt_ml_state' "
|
|
157
|
+
"ORDER BY table_name",
|
|
158
|
+
[self.catalog, self.schema],
|
|
159
|
+
).fetchall()
|
|
160
|
+
return [r[0] for r in rows]
|
|
161
|
+
|
|
162
|
+
# ─── state CRUD ──────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
def fetch_state(self, model_name: str) -> dict[str, tuple[str, str]]:
|
|
165
|
+
rows = self.connection.execute(
|
|
166
|
+
f"SELECT document_id, content_hash, code_version "
|
|
167
|
+
f"FROM {self.schema_ref}.dbt_ml_state WHERE model_name = ?",
|
|
168
|
+
[model_name],
|
|
169
|
+
).fetchall()
|
|
170
|
+
return {r[0]: (r[1], r[2]) for r in rows}
|
|
171
|
+
|
|
172
|
+
def upsert_state(
|
|
173
|
+
self, model_name: str, records: list[tuple[str, str, str]]
|
|
174
|
+
) -> None:
|
|
175
|
+
if not records:
|
|
176
|
+
return
|
|
177
|
+
self.connection.executemany(
|
|
178
|
+
f"""
|
|
179
|
+
INSERT INTO {self.schema_ref}.dbt_ml_state
|
|
180
|
+
(model_name, document_id, content_hash, code_version, last_run_at)
|
|
181
|
+
VALUES (?, ?, ?, ?, current_timestamp)
|
|
182
|
+
ON CONFLICT (model_name, document_id) DO UPDATE SET
|
|
183
|
+
content_hash = excluded.content_hash,
|
|
184
|
+
code_version = excluded.code_version,
|
|
185
|
+
last_run_at = excluded.last_run_at
|
|
186
|
+
""",
|
|
187
|
+
[[model_name, doc_id, ch, cv] for doc_id, ch, cv in records],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def clear_model_state(self, model_name: str) -> None:
|
|
191
|
+
self.connection.execute(
|
|
192
|
+
f"DELETE FROM {self.schema_ref}.dbt_ml_state WHERE model_name = ?",
|
|
193
|
+
[model_name],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# ─── internals ───────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def _resolved_path(self) -> Path:
|
|
199
|
+
path = self.config.path
|
|
200
|
+
if path.is_absolute() or self.project_dir is None:
|
|
201
|
+
return path.resolve()
|
|
202
|
+
return (self.project_dir / path).resolve()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ..config.profile import WarehouseConfig
|
|
6
|
+
from .base import AdapterError, WarehouseAdapter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UnknownAdapterError(AdapterError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_REGISTRY: dict[str, type[WarehouseAdapter]] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(cls: type[WarehouseAdapter]) -> type[WarehouseAdapter]:
|
|
17
|
+
_REGISTRY[cls.adapter_type()] = cls
|
|
18
|
+
return cls
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_adapter(
|
|
22
|
+
config: WarehouseConfig, *, project_dir: Path | None = None
|
|
23
|
+
) -> WarehouseAdapter:
|
|
24
|
+
cls = _REGISTRY.get(config.type)
|
|
25
|
+
if cls is None:
|
|
26
|
+
raise UnknownAdapterError(
|
|
27
|
+
f"No adapter registered for warehouse.type='{config.type}'. "
|
|
28
|
+
f"Known: {sorted(_REGISTRY)}"
|
|
29
|
+
)
|
|
30
|
+
return cls(config, project_dir=project_dir)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_adapter_types() -> list[str]:
|
|
34
|
+
return sorted(_REGISTRY)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from . import (
|
|
2
|
+
email_backend, # noqa: F401 # side-effect: registers EmailBackend
|
|
3
|
+
html_backend, # noqa: F401 # side-effect: registers HtmlBackend
|
|
4
|
+
json_backend, # noqa: F401 # side-effect: registers JsonBackend
|
|
5
|
+
llm_backend, # noqa: F401 # side-effect: registers LLMBackend
|
|
6
|
+
markdown_backend, # noqa: F401 # side-effect: registers MarkdownBackend
|
|
7
|
+
pdf_backend, # noqa: F401 # side-effect: registers PdfBackend
|
|
8
|
+
)
|
|
9
|
+
from .base import BaseBackend, ExtractionResult
|
|
10
|
+
from .registry import BackendNotFoundError, get_backend, list_backends, register
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BackendNotFoundError",
|
|
14
|
+
"BaseBackend",
|
|
15
|
+
"ExtractionResult",
|
|
16
|
+
"get_backend",
|
|
17
|
+
"list_backends",
|
|
18
|
+
"register",
|
|
19
|
+
]
|
dbt_ml/backends/base.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ExtractionResult:
|
|
11
|
+
"""Output of a single document extraction.
|
|
12
|
+
|
|
13
|
+
`fields` holds the projected field values. `warnings` collects
|
|
14
|
+
non-fatal issues surfaced by the backend.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
fields: dict[str, Any]
|
|
18
|
+
warnings: list[str] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseBackend(ABC):
|
|
22
|
+
"""Contract every extraction backend implements."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def name(self) -> str: ...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def supported_formats(self) -> list[str]: ...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def extract(self, path: Path, options: dict[str, Any]) -> ExtractionResult: ...
|
|
32
|
+
|
|
33
|
+
def validate(self) -> None:
|
|
34
|
+
"""Raise if the backend's runtime deps are missing. Default: no-op."""
|
|
35
|
+
return None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from email import message_from_bytes
|
|
4
|
+
from email.message import Message
|
|
5
|
+
from email.utils import parsedate_to_datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .base import BaseBackend, ExtractionResult
|
|
10
|
+
from .registry import register
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@register
|
|
14
|
+
class EmailBackend(BaseBackend):
|
|
15
|
+
"""Read .eml files via stdlib `email`. No external dependencies.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
include_body: Emit the plaintext body (default True).
|
|
19
|
+
body_field: Field name for the body (default "body").
|
|
20
|
+
include_html: Emit the HTML alternative if present (default False).
|
|
21
|
+
include_headers: Emit a `headers` dict of all headers (default False).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def name(self) -> str:
|
|
25
|
+
return "email"
|
|
26
|
+
|
|
27
|
+
def supported_formats(self) -> list[str]:
|
|
28
|
+
return [".eml"]
|
|
29
|
+
|
|
30
|
+
def extract(self, path: Path, options: dict[str, Any]) -> ExtractionResult:
|
|
31
|
+
msg: Message = message_from_bytes(path.read_bytes())
|
|
32
|
+
warnings: list[str] = []
|
|
33
|
+
fields: dict[str, Any] = {
|
|
34
|
+
"from": msg.get("From"),
|
|
35
|
+
"to": msg.get("To"),
|
|
36
|
+
"cc": msg.get("Cc"),
|
|
37
|
+
"subject": msg.get("Subject"),
|
|
38
|
+
"date": _parse_date(msg.get("Date")),
|
|
39
|
+
"message_id": msg.get("Message-ID"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
text_body, html_body = _walk_parts(msg)
|
|
43
|
+
if options.get("include_body", True):
|
|
44
|
+
body_field = options.get("body_field", "body")
|
|
45
|
+
if text_body is None and html_body is not None:
|
|
46
|
+
warnings.append(f"{path.name}: no text/plain part, fell back to text/html")
|
|
47
|
+
fields[body_field] = html_body
|
|
48
|
+
else:
|
|
49
|
+
fields[body_field] = text_body or ""
|
|
50
|
+
if not (text_body or html_body):
|
|
51
|
+
warnings.append(f"{path.name}: no text or html body found")
|
|
52
|
+
|
|
53
|
+
if options.get("include_html", False):
|
|
54
|
+
fields["html_body"] = html_body
|
|
55
|
+
|
|
56
|
+
if options.get("include_headers", False):
|
|
57
|
+
fields["headers"] = {k: v for k, v in msg.items()}
|
|
58
|
+
|
|
59
|
+
return ExtractionResult(fields=fields, warnings=warnings)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_date(raw: str | None) -> str | None:
|
|
63
|
+
if not raw:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
return parsedate_to_datetime(raw).isoformat()
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return raw
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _walk_parts(msg: Message) -> tuple[str | None, str | None]:
|
|
72
|
+
"""Return (text_body, html_body) — preferring multipart/alternative parts."""
|
|
73
|
+
text_body: str | None = None
|
|
74
|
+
html_body: str | None = None
|
|
75
|
+
for part in msg.walk():
|
|
76
|
+
ctype = part.get_content_type()
|
|
77
|
+
if part.is_multipart():
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
payload = part.get_payload(decode=True)
|
|
81
|
+
except Exception:
|
|
82
|
+
continue
|
|
83
|
+
if payload is None:
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
decoded = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
|
87
|
+
except (LookupError, ValueError):
|
|
88
|
+
decoded = payload.decode("utf-8", errors="replace")
|
|
89
|
+
if ctype == "text/plain" and text_body is None:
|
|
90
|
+
text_body = decoded
|
|
91
|
+
elif ctype == "text/html" and html_body is None:
|
|
92
|
+
html_body = decoded
|
|
93
|
+
return text_body, html_body
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
from .base import BaseBackend, ExtractionResult
|
|
9
|
+
from .registry import register
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@register
|
|
13
|
+
class HtmlBackend(BaseBackend):
|
|
14
|
+
"""Read .html files via BeautifulSoup.
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
text_field: Field name for the plain-text body (default "text").
|
|
18
|
+
include_text: Emit body text with tags stripped (default True).
|
|
19
|
+
selectors: dict of {field_name: css_selector}. First match's text
|
|
20
|
+
per selector is emitted; missing selectors yield None
|
|
21
|
+
with a warning.
|
|
22
|
+
include_meta: Emit a `meta` dict of <meta> name→content pairs.
|
|
23
|
+
include_opengraph: Emit `og` dict of OpenGraph properties (og:*).
|
|
24
|
+
include_links: Emit `links` as a list of href strings.
|
|
25
|
+
parser: "html.parser" (default, stdlib) or "lxml" if installed.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
return "html"
|
|
30
|
+
|
|
31
|
+
def supported_formats(self) -> list[str]:
|
|
32
|
+
return [".html", ".htm"]
|
|
33
|
+
|
|
34
|
+
def extract(self, path: Path, options: dict[str, Any]) -> ExtractionResult:
|
|
35
|
+
parser = options.get("parser", "html.parser")
|
|
36
|
+
soup = BeautifulSoup(path.read_text(), parser)
|
|
37
|
+
|
|
38
|
+
warnings: list[str] = []
|
|
39
|
+
fields: dict[str, Any] = {}
|
|
40
|
+
|
|
41
|
+
if options.get("include_text", True):
|
|
42
|
+
text_field = options.get("text_field", "text")
|
|
43
|
+
body = soup.body or soup
|
|
44
|
+
fields[text_field] = body.get_text(separator="\n", strip=True)
|
|
45
|
+
|
|
46
|
+
selectors = options.get("selectors") or {}
|
|
47
|
+
for field_name, selector in selectors.items():
|
|
48
|
+
match = soup.select_one(selector)
|
|
49
|
+
if match is None:
|
|
50
|
+
warnings.append(
|
|
51
|
+
f"selector {selector!r} for field '{field_name}' matched nothing"
|
|
52
|
+
)
|
|
53
|
+
fields[field_name] = None
|
|
54
|
+
else:
|
|
55
|
+
fields[field_name] = match.get_text(strip=True)
|
|
56
|
+
|
|
57
|
+
if options.get("include_meta", False):
|
|
58
|
+
meta: dict[str, str] = {}
|
|
59
|
+
for tag in soup.find_all("meta"):
|
|
60
|
+
key = tag.get("name") or tag.get("property")
|
|
61
|
+
content = tag.get("content")
|
|
62
|
+
if key and content:
|
|
63
|
+
meta[str(key)] = str(content)
|
|
64
|
+
fields["meta"] = meta
|
|
65
|
+
|
|
66
|
+
if options.get("include_opengraph", False):
|
|
67
|
+
og: dict[str, str] = {}
|
|
68
|
+
for tag in soup.find_all("meta"):
|
|
69
|
+
prop = tag.get("property")
|
|
70
|
+
if prop and isinstance(prop, str) and prop.startswith("og:"):
|
|
71
|
+
og[prop[3:]] = str(tag.get("content") or "")
|
|
72
|
+
fields["og"] = og
|
|
73
|
+
|
|
74
|
+
if options.get("include_links", False):
|
|
75
|
+
fields["links"] = [
|
|
76
|
+
str(a.get("href"))
|
|
77
|
+
for a in soup.find_all("a", href=True)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
return ExtractionResult(fields=fields, warnings=warnings)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import BaseBackend, ExtractionResult
|
|
8
|
+
from .registry import register
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register
|
|
12
|
+
class JsonBackend(BaseBackend):
|
|
13
|
+
def name(self) -> str:
|
|
14
|
+
return "json"
|
|
15
|
+
|
|
16
|
+
def supported_formats(self) -> list[str]:
|
|
17
|
+
return [".json"]
|
|
18
|
+
|
|
19
|
+
def extract(self, path: Path, options: dict[str, Any]) -> ExtractionResult:
|
|
20
|
+
with path.open() as f:
|
|
21
|
+
data = json.load(f)
|
|
22
|
+
|
|
23
|
+
if not isinstance(data, dict):
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"json backend expects each document to be a JSON object; "
|
|
26
|
+
f"got {type(data).__name__} from {path}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
fields_to_project = options.get("fields")
|
|
30
|
+
warnings: list[str] = []
|
|
31
|
+
if fields_to_project:
|
|
32
|
+
extracted: dict[str, Any] = {}
|
|
33
|
+
for key in fields_to_project:
|
|
34
|
+
if key not in data:
|
|
35
|
+
warnings.append(f"Field '{key}' missing in {path.name}")
|
|
36
|
+
extracted[key] = data.get(key)
|
|
37
|
+
else:
|
|
38
|
+
extracted = dict(data)
|
|
39
|
+
|
|
40
|
+
return ExtractionResult(fields=extracted, warnings=warnings)
|