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.
Files changed (88) hide show
  1. dbt_ml/__init__.py +1 -0
  2. dbt_ml/adapters/__init__.py +12 -0
  3. dbt_ml/adapters/base.py +142 -0
  4. dbt_ml/adapters/duckdb.py +202 -0
  5. dbt_ml/adapters/registry.py +34 -0
  6. dbt_ml/backends/__init__.py +19 -0
  7. dbt_ml/backends/base.py +35 -0
  8. dbt_ml/backends/email_backend.py +93 -0
  9. dbt_ml/backends/html_backend.py +80 -0
  10. dbt_ml/backends/json_backend.py +40 -0
  11. dbt_ml/backends/llm_backend.py +238 -0
  12. dbt_ml/backends/markdown_backend.py +78 -0
  13. dbt_ml/backends/pdf_backend.py +63 -0
  14. dbt_ml/backends/registry.py +30 -0
  15. dbt_ml/checks/__init__.py +4 -0
  16. dbt_ml/checks/python.py +63 -0
  17. dbt_ml/checks/runner.py +68 -0
  18. dbt_ml/checks/schema.py +214 -0
  19. dbt_ml/cli.py +671 -0
  20. dbt_ml/config/__init__.py +19 -0
  21. dbt_ml/config/loader.py +54 -0
  22. dbt_ml/config/model.py +41 -0
  23. dbt_ml/config/profile.py +42 -0
  24. dbt_ml/config/project.py +37 -0
  25. dbt_ml/config/source.py +34 -0
  26. dbt_ml/dag.py +181 -0
  27. dbt_ml/dbt_export.py +171 -0
  28. dbt_ml/docs.py +120 -0
  29. dbt_ml/freshness.py +103 -0
  30. dbt_ml/manifest.py +132 -0
  31. dbt_ml/profile.py +179 -0
  32. dbt_ml/runner.py +341 -0
  33. dbt_ml/synth/__init__.py +17 -0
  34. dbt_ml/synth/invoice_pdfs.py +107 -0
  35. dbt_ml/synth/invoice_text.py +64 -0
  36. dbt_ml/synth/invoices.py +50 -0
  37. dbt_ml/synth/posts.py +55 -0
  38. dbt_ml/synth/product_html.py +78 -0
  39. dbt_ml/synth/support_emails.py +50 -0
  40. dbt_ml/synth/support_tickets.py +81 -0
  41. dbt_ml/templates/docs/base.html +52 -0
  42. dbt_ml/templates/docs/index.html +54 -0
  43. dbt_ml/templates/docs/lineage.html +29 -0
  44. dbt_ml/templates/docs/model.html +67 -0
  45. dbt_ml/templates/html/dbt_ml_project.yml +11 -0
  46. dbt_ml/templates/html/models/raw_pages.yml +28 -0
  47. dbt_ml/templates/html/profiles.yml +8 -0
  48. dbt_ml/templates/html/sources/pages.yml +8 -0
  49. dbt_ml/templates/html/transforms/.gitkeep +0 -0
  50. dbt_ml/templates/json/.gitkeep +0 -0
  51. dbt_ml/templates/json/dbt_ml_project.yml +11 -0
  52. dbt_ml/templates/json/models/raw_invoices.yml +25 -0
  53. dbt_ml/templates/json/profiles.yml +8 -0
  54. dbt_ml/templates/json/sources/invoices.yml +8 -0
  55. dbt_ml/templates/json/transforms/.gitkeep +0 -0
  56. dbt_ml/templates/markdown/dbt_ml_project.yml +11 -0
  57. dbt_ml/templates/markdown/models/raw_documents.yml +24 -0
  58. dbt_ml/templates/markdown/profiles.yml +8 -0
  59. dbt_ml/templates/markdown/sources/documents.yml +8 -0
  60. dbt_ml/templates/markdown/transforms/.gitkeep +0 -0
  61. dbt_ml/templates/pdf/dbt_ml_project.yml +11 -0
  62. dbt_ml/templates/pdf/models/raw_pdf_text.yml +24 -0
  63. dbt_ml/templates/pdf/profiles.yml +13 -0
  64. dbt_ml/templates/pdf/sources/documents.yml +11 -0
  65. dbt_ml/templates/pdf/transforms/.gitkeep +0 -0
  66. dbt_ml/text/__init__.py +33 -0
  67. dbt_ml/text/dedup.py +68 -0
  68. dbt_ml/text/encoding.py +16 -0
  69. dbt_ml/text/language.py +21 -0
  70. dbt_ml/text/pii.py +173 -0
  71. dbt_ml/text/stats.py +35 -0
  72. dbt_ml/text/tokens.py +49 -0
  73. dbt_ml/text/transforms/__init__.py +12 -0
  74. dbt_ml/text/transforms/_helpers.py +23 -0
  75. dbt_ml/text/transforms/clean_encoding.py +28 -0
  76. dbt_ml/text/transforms/count_tokens.py +30 -0
  77. dbt_ml/text/transforms/detect_language.py +30 -0
  78. dbt_ml/text/transforms/find_duplicates.py +43 -0
  79. dbt_ml/text/transforms/redact_pii.py +64 -0
  80. dbt_ml/text/transforms/text_stats.py +36 -0
  81. dbt_ml/transforms/__init__.py +3 -0
  82. dbt_ml/transforms/runner.py +69 -0
  83. dbt_ml/versioning.py +46 -0
  84. dbt_ml-0.1.0.dist-info/METADATA +346 -0
  85. dbt_ml-0.1.0.dist-info/RECORD +88 -0
  86. dbt_ml-0.1.0.dist-info/WHEEL +4 -0
  87. dbt_ml-0.1.0.dist-info/entry_points.txt +2 -0
  88. 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
+ ]
@@ -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
+ ]
@@ -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)