det-core 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.
det/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """det: Data Engineering Toolkit by det Labs."""
2
+
3
+ __version__ = "0.1.0"
det/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Entrypoint for python -m det."""
2
+
3
+ from det.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
7
+
@@ -0,0 +1,17 @@
1
+ """det.catalog - YAML metadata catalog module."""
2
+
3
+ from det.catalog.config import get_default_catalog_path, set_default_catalog_path
4
+ from det.catalog.models import CatalogFile, ColumnMetadata, DatasetMetadata, ScanResult
5
+ from det.catalog.scanner import scan_location
6
+ from det.catalog.service import CatalogService
7
+
8
+ __all__ = [
9
+ "CatalogFile",
10
+ "CatalogService",
11
+ "ColumnMetadata",
12
+ "DatasetMetadata",
13
+ "ScanResult",
14
+ "get_default_catalog_path",
15
+ "scan_location",
16
+ "set_default_catalog_path",
17
+ ]
det/catalog/config.py ADDED
@@ -0,0 +1,170 @@
1
+ """det.catalog.config - Manages catalog preferences and default YAML paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ # Local configuration directory for project-level settings
10
+ CONFIG_DIR = Path(".det")
11
+ CONFIG_FILE = CONFIG_DIR / "config.json"
12
+ DEFAULT_CATALOG_FILENAME = "catalog.yaml"
13
+
14
+
15
+ def get_default_catalog_path() -> Path:
16
+ """Retrieve the configured default catalog path from local configuration.
17
+
18
+ If no configuration file exists or reading fails, safely defaults to
19
+ './catalog.yaml' in the current working directory.
20
+
21
+ Returns:
22
+ Path: Absolute resolved path to the default catalog YAML file.
23
+ """
24
+ if CONFIG_FILE.is_file():
25
+ try:
26
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
27
+ data = json.load(f)
28
+ if isinstance(data, dict) and data.get("default_catalog_path"):
29
+ return Path(data["default_catalog_path"]).resolve()
30
+ except (json.JSONDecodeError, OSError):
31
+ return Path(DEFAULT_CATALOG_FILENAME).resolve()
32
+
33
+ return Path(DEFAULT_CATALOG_FILENAME).resolve()
34
+
35
+
36
+ def set_default_catalog_path(path: str | Path) -> Path:
37
+ """Persist the default catalog path into the local configuration file.
38
+
39
+ Args:
40
+ path: Path string or Path object pointing to the catalog YAML file.
41
+
42
+ Returns:
43
+ Path: Absolute resolved path that was persisted.
44
+
45
+ Raises:
46
+ OSError: If creating directories or writing the configuration fails.
47
+ """
48
+ resolved_path = Path(path).resolve()
49
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
50
+
51
+ data: dict[str, Any] = {}
52
+ if CONFIG_FILE.is_file():
53
+ try:
54
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
55
+ loaded = json.load(f)
56
+ if isinstance(loaded, dict):
57
+ data = loaded
58
+ except (json.JSONDecodeError, OSError):
59
+ data = {}
60
+
61
+ data["default_catalog_path"] = str(resolved_path)
62
+
63
+ # Ensure default is also in known catalogs
64
+ known = data.get("known_catalogs", [])
65
+ if not isinstance(known, list):
66
+ known = []
67
+ path_str = str(resolved_path)
68
+ if path_str not in known:
69
+ known.append(path_str)
70
+ data["known_catalogs"] = known
71
+
72
+ # Write configuration safely
73
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
74
+ json.dump(data, f, indent=2)
75
+
76
+ return resolved_path
77
+
78
+
79
+ def get_known_catalogs() -> list[Path]:
80
+ """Retrieve all known catalog YAML paths.
81
+
82
+ Includes the default catalog, any registered catalogs from local config,
83
+ and any existing 'catalog.yaml' in the project root.
84
+
85
+ Returns:
86
+ list[Path]: List of resolved Path objects for all known catalogs.
87
+ """
88
+ catalogs: list[Path] = []
89
+ default_cat = get_default_catalog_path()
90
+ if default_cat.is_file():
91
+ catalogs.append(default_cat)
92
+
93
+ if CONFIG_FILE.is_file():
94
+ try:
95
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
96
+ data = json.load(f)
97
+ if isinstance(data, dict):
98
+ raw_list = data.get("known_catalogs", [])
99
+ if isinstance(raw_list, list):
100
+ for p in raw_list:
101
+ resolved = Path(p).resolve()
102
+ if resolved.is_file() and resolved not in catalogs:
103
+ catalogs.append(resolved)
104
+ except (json.JSONDecodeError, OSError):
105
+ pass
106
+
107
+ # If no existing file found yet, ensure default_cat is listed
108
+ if not catalogs:
109
+ catalogs.append(default_cat)
110
+
111
+ # Deduplicate while preserving order
112
+ result: list[Path] = []
113
+ for c in catalogs:
114
+ if c not in result:
115
+ result.append(c)
116
+ return result
117
+
118
+
119
+ def add_known_catalog(path: str | Path) -> Path:
120
+ """Register a catalog path in the known catalogs configuration list."""
121
+ resolved_path = Path(path).resolve()
122
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
123
+
124
+ data: dict[str, Any] = {}
125
+ if CONFIG_FILE.is_file():
126
+ try:
127
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
128
+ loaded = json.load(f)
129
+ if isinstance(loaded, dict):
130
+ data = loaded
131
+ except (json.JSONDecodeError, OSError):
132
+ data = {}
133
+
134
+ known = data.get("known_catalogs", [])
135
+ if not isinstance(known, list):
136
+ known = []
137
+
138
+ path_str = str(resolved_path)
139
+ if path_str not in known:
140
+ known.append(path_str)
141
+
142
+ data["known_catalogs"] = known
143
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
144
+ json.dump(data, f, indent=2)
145
+
146
+ return resolved_path
147
+
148
+
149
+ def remove_known_catalog(path: str | Path) -> bool:
150
+ """Remove a catalog from the known catalogs configuration list."""
151
+ resolved_path = Path(path).resolve()
152
+ if not CONFIG_FILE.is_file():
153
+ return False
154
+
155
+ try:
156
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
157
+ data = json.load(f)
158
+ if not isinstance(data, dict):
159
+ return False
160
+
161
+ known = data.get("known_catalogs", [])
162
+ if isinstance(known, list) and str(resolved_path) in known:
163
+ known.remove(str(resolved_path))
164
+ data["known_catalogs"] = known
165
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
166
+ json.dump(data, f, indent=2)
167
+ return True
168
+ except (json.JSONDecodeError, OSError):
169
+ pass
170
+ return False
det/catalog/models.py ADDED
@@ -0,0 +1,50 @@
1
+ """det.catalog.models - Pydantic data models for Catalog items, schemas, and files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class ColumnMetadata(BaseModel):
11
+ """Metadata for a single dataset column."""
12
+
13
+ name: str = Field(..., description="Column identifier / name")
14
+ type: str = Field(..., description="Logical or native data type (e.g. string, int64, float64)")
15
+ nullable: bool = Field(True, description="Whether the column accepts null values")
16
+ description: str = Field("", description="Human-readable column description")
17
+
18
+
19
+ class DatasetMetadata(BaseModel):
20
+ """Metadata describing a table or file registered in the catalog."""
21
+
22
+ id: str = Field(..., description="Unique dataset identifier (e.g. namespace.table_name)")
23
+ name: str = Field(..., description="Dataset or table name")
24
+ namespace: str = Field("default", description="Namespace / schema grouping")
25
+ format: str = Field("parquet", description="Data format (parquet, csv, json, excel, directory)")
26
+ location: str = Field(..., description="Absolute or relative file / folder path")
27
+ description: str = Field("", description="Detailed description of the dataset")
28
+ row_count: int | None = Field(None, description="Exact or estimated total row count")
29
+ columns: list[ColumnMetadata] = Field(default_factory=list, description="Dataset column definitions")
30
+ tags: list[str] = Field(default_factory=list, description="Categorization tags")
31
+ last_scanned: str | None = Field(None, description="ISO timestamp of when schema was last scanned")
32
+
33
+
34
+ class CatalogFile(BaseModel):
35
+ """The root representation serialized to catalog.yaml."""
36
+
37
+ catalog_version: int = Field(1, description="Catalog specification version")
38
+ catalog_name: str = Field("det_catalog", description="Logical name of this catalog")
39
+ namespaces: list[str] = Field(default_factory=lambda: ["default"], description="Configured namespaces / categories")
40
+ datasets: list[DatasetMetadata] = Field(default_factory=list, description="List of registered datasets")
41
+
42
+
43
+ class ScanResult(BaseModel):
44
+ """Result of auto-scanning a file or folder location."""
45
+
46
+ location: str
47
+ format: str
48
+ columns: list[ColumnMetadata]
49
+ row_count: int | None = None
50
+ sample_preview: list[dict[str, Any]] | None = None
det/catalog/scanner.py ADDED
@@ -0,0 +1,274 @@
1
+ """det.catalog.scanner - Schema auto-scanner for files and directory datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ # Python 3.15 lazy imports: modules are deferred until attribute access
8
+ lazy import polars as pl
9
+ lazy import pyarrow.dataset as ds
10
+ lazy import pyarrow.parquet as pq
11
+
12
+ from det.catalog.models import ColumnMetadata, ScanResult
13
+
14
+
15
+ def _normalize_polars_type(pl_type: pl.DataType) -> str:
16
+ """Normalize a Polars DataType to a standard logical type string.
17
+
18
+ Args:
19
+ pl_type: The raw Polars datatype.
20
+
21
+ Returns:
22
+ str: Clean logical type string (e.g. 'int64', 'string', 'datetime').
23
+ """
24
+ name = str(pl_type).lower()
25
+ if "string" in name or "utf8" in name:
26
+ return "string"
27
+ if "int64" in name:
28
+ return "int64"
29
+ if "int32" in name:
30
+ return "int32"
31
+ if "int16" in name:
32
+ return "int16"
33
+ if "int8" in name:
34
+ return "int8"
35
+ if "uint" in name:
36
+ return "uint64"
37
+ if "float64" in name:
38
+ return "float64"
39
+ if "float32" in name:
40
+ return "float32"
41
+ if "bool" in name:
42
+ return "boolean"
43
+ if "datetime" in name:
44
+ return "datetime"
45
+ if "date" in name:
46
+ return "date"
47
+ if "decimal" in name:
48
+ return "decimal"
49
+ if "list" in name:
50
+ return "list"
51
+ if "struct" in name:
52
+ return "struct"
53
+ return str(pl_type)
54
+
55
+
56
+ def scan_location(location: str | Path, format_hint: str | None = None) -> ScanResult:
57
+ """Scan a file or directory path and infer schema, format, and row count.
58
+
59
+ Args:
60
+ location: Path to a file or directory.
61
+ format_hint: Optional manual format indicator (e.g. 'parquet', 'csv').
62
+
63
+ Returns:
64
+ ScanResult: Object containing inferred columns, row count, and sample records.
65
+
66
+ Raises:
67
+ FileNotFoundError: If the specified location does not exist.
68
+ ValueError: If the location is not a file/dir or file format is unsupported.
69
+ """
70
+ path = Path(location).resolve()
71
+ if not path.exists():
72
+ raise FileNotFoundError(f"Location does not exist: {location}")
73
+
74
+ if path.is_file():
75
+ return _scan_file(path, format_hint)
76
+ elif path.is_dir():
77
+ return _scan_directory(path, format_hint)
78
+ else:
79
+ raise ValueError(f"Path is neither a regular file nor a directory: {location}")
80
+
81
+
82
+ def _scan_file(path: Path, format_hint: str | None = None) -> ScanResult:
83
+ """Inspect and extract schema from a single file."""
84
+ ext = path.suffix.lower().lstrip(".")
85
+ detected_format = (format_hint or ext).lower()
86
+
87
+ if detected_format in ("parquet", "pq"):
88
+ try:
89
+ pq_file = pq.ParquetFile(str(path))
90
+ schema = pq_file.schema_arrow
91
+ row_count = pq_file.metadata.num_rows
92
+
93
+ columns = [
94
+ ColumnMetadata(
95
+ name=field.name,
96
+ type=str(field.type),
97
+ nullable=field.nullable,
98
+ )
99
+ for field in schema
100
+ ]
101
+
102
+ # Sample preview of up to 5 rows
103
+ sample_df = pl.read_parquet(str(path), n_rows=5)
104
+ sample_data = sample_df.to_dicts()
105
+
106
+ return ScanResult(
107
+ location=str(path),
108
+ format="parquet",
109
+ columns=columns,
110
+ row_count=row_count,
111
+ sample_preview=sample_data,
112
+ )
113
+ except (OSError, RuntimeError):
114
+ # Fallback to Polars lazy scan
115
+ df = pl.scan_parquet(str(path)).collect().head(5)
116
+ columns = [
117
+ ColumnMetadata(name=col, type=_normalize_polars_type(df.schema[col]))
118
+ for col in df.columns
119
+ ]
120
+ return ScanResult(
121
+ location=str(path),
122
+ format="parquet",
123
+ columns=columns,
124
+ sample_preview=df.to_dicts(),
125
+ )
126
+
127
+ elif detected_format in ("csv", "tsv", "txt"):
128
+ separator = "\t" if detected_format == "tsv" or ext == "tsv" else ","
129
+ df = pl.read_csv(str(path), separator=separator, n_rows=100, ignore_errors=True)
130
+ columns = [
131
+ ColumnMetadata(name=col, type=_normalize_polars_type(df.schema[col]))
132
+ for col in df.columns
133
+ ]
134
+
135
+ # Calculate exact row count safely
136
+ try:
137
+ total_rows = (
138
+ pl.scan_csv(str(path), separator=separator)
139
+ .select(pl.len())
140
+ .collect()
141
+ .item()
142
+ )
143
+ except (pl.exceptions.PolarsError, OSError):
144
+ total_rows = len(df)
145
+
146
+ return ScanResult(
147
+ location=str(path),
148
+ format="csv" if separator == "," else "tsv",
149
+ columns=columns,
150
+ row_count=total_rows,
151
+ sample_preview=df.head(5).to_dicts(),
152
+ )
153
+
154
+ elif detected_format in ("json", "ndjson", "jsonl"):
155
+ try:
156
+ df = pl.read_ndjson(str(path), n_rows=100)
157
+ columns = [
158
+ ColumnMetadata(name=col, type=_normalize_polars_type(df.schema[col]))
159
+ for col in df.columns
160
+ ]
161
+ return ScanResult(
162
+ location=str(path),
163
+ format="ndjson",
164
+ columns=columns,
165
+ row_count=len(df),
166
+ sample_preview=df.head(5).to_dicts(),
167
+ )
168
+ except (pl.exceptions.PolarsError, ValueError):
169
+ # Fallback to standard JSON
170
+ df = pl.read_json(str(path))
171
+ columns = [
172
+ ColumnMetadata(name=col, type=_normalize_polars_type(df.schema[col]))
173
+ for col in df.columns
174
+ ]
175
+ return ScanResult(
176
+ location=str(path),
177
+ format="json",
178
+ columns=columns,
179
+ row_count=len(df),
180
+ sample_preview=df.head(5).to_dicts(),
181
+ )
182
+
183
+ elif detected_format in ("xlsx", "xls"):
184
+ df = pl.read_excel(str(path), read_options={"n_rows": 100})
185
+ columns = [
186
+ ColumnMetadata(name=col, type=_normalize_polars_type(df.schema[col]))
187
+ for col in df.columns
188
+ ]
189
+ return ScanResult(
190
+ location=str(path),
191
+ format="excel",
192
+ columns=columns,
193
+ row_count=len(df),
194
+ sample_preview=df.head(5).to_dicts(),
195
+ )
196
+
197
+ else:
198
+ raise ValueError(f"Unsupported file format: .{ext} for file '{path.name}'")
199
+
200
+
201
+ def _scan_directory(dir_path: Path, format_hint: str | None = None) -> ScanResult:
202
+ """Inspect and extract schema from a directory dataset."""
203
+ parquet_files = list(dir_path.rglob("*.parquet")) + list(dir_path.rglob("*.pq"))
204
+ if parquet_files or format_hint == "parquet":
205
+ source = [str(f) for f in parquet_files] if parquet_files else str(dir_path)
206
+ dataset = ds.dataset(source, format="parquet")
207
+ schema = dataset.schema
208
+ columns = [
209
+ ColumnMetadata(
210
+ name=field.name,
211
+ type=str(field.type),
212
+ nullable=field.nullable,
213
+ )
214
+ for field in schema
215
+ ]
216
+ sample_data: list[dict] = []
217
+ try:
218
+ parquet_glob = f"{dir_path.as_posix()}/**/*.parquet"
219
+ sample_df = pl.scan_parquet(parquet_glob).collect().head(5)
220
+ sample_data = sample_df.to_dicts()
221
+ except (pl.exceptions.PolarsError, OSError, RuntimeError, ValueError):
222
+ if parquet_files:
223
+ sample_data = pl.read_parquet(str(parquet_files[0]), n_rows=5).to_dicts()
224
+
225
+ return ScanResult(
226
+ location=str(dir_path),
227
+ format="parquet (directory)",
228
+ columns=columns,
229
+ sample_preview=sample_data,
230
+ )
231
+
232
+ csv_files = list(dir_path.rglob("*.csv"))
233
+ if csv_files or format_hint == "csv":
234
+ sample_file = csv_files[0] if csv_files else None
235
+ if sample_file:
236
+ df = pl.read_csv(str(sample_file), n_rows=100)
237
+ columns = [
238
+ ColumnMetadata(name=col, type=_normalize_polars_type(df.schema[col]))
239
+ for col in df.columns
240
+ ]
241
+ return ScanResult(
242
+ location=str(dir_path),
243
+ format="csv (directory)",
244
+ columns=columns,
245
+ sample_preview=df.head(5).to_dicts(),
246
+ )
247
+
248
+ json_files = (
249
+ list(dir_path.rglob("*.ndjson"))
250
+ + list(dir_path.rglob("*.jsonl"))
251
+ + list(dir_path.rglob("*.json"))
252
+ )
253
+ if json_files or format_hint in ("json", "ndjson", "jsonl"):
254
+ sample_file = json_files[0] if json_files else None
255
+ if sample_file:
256
+ try:
257
+ df = pl.read_ndjson(str(sample_file), n_rows=100)
258
+ fmt = "ndjson (directory)"
259
+ except (pl.exceptions.PolarsError, OSError, ValueError):
260
+ df = pl.read_json(str(sample_file))
261
+ fmt = "json (directory)"
262
+
263
+ columns = [
264
+ ColumnMetadata(name=col, type=_normalize_polars_type(df.schema[col]))
265
+ for col in df.columns
266
+ ]
267
+ return ScanResult(
268
+ location=str(dir_path),
269
+ format=fmt,
270
+ columns=columns,
271
+ sample_preview=df.head(5).to_dicts(),
272
+ )
273
+
274
+ raise ValueError(f"Could not automatically detect dataset files inside directory: '{dir_path}'")