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 +3 -0
- det/__main__.py +7 -0
- det/catalog/__init__.py +17 -0
- det/catalog/config.py +170 -0
- det/catalog/models.py +50 -0
- det/catalog/scanner.py +274 -0
- det/catalog/service.py +390 -0
- det/catalog/store.py +74 -0
- det/cli/__init__.py +119 -0
- det/gui/__init__.py +83 -0
- det/gui/__main__.py +7 -0
- det/gui/server.py +408 -0
- det/gui/static/index.html +2131 -0
- det_core-0.1.0.dist-info/METADATA +206 -0
- det_core-0.1.0.dist-info/RECORD +19 -0
- det_core-0.1.0.dist-info/WHEEL +5 -0
- det_core-0.1.0.dist-info/entry_points.txt +3 -0
- det_core-0.1.0.dist-info/licenses/LICENSE +201 -0
- det_core-0.1.0.dist-info/top_level.txt +1 -0
det/__init__.py
ADDED
det/__main__.py
ADDED
det/catalog/__init__.py
ADDED
|
@@ -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}'")
|