localql 1.0.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.
- csvql/__init__.py +23 -0
- csvql/__main__.py +6 -0
- csvql/api.py +154 -0
- csvql/atomic_write.py +81 -0
- csvql/benchmark_data.py +167 -0
- csvql/benchmark_runner.py +363 -0
- csvql/benchmarking.py +218 -0
- csvql/checks.py +747 -0
- csvql/cli.py +527 -0
- csvql/doctor.py +338 -0
- csvql/engine.py +59 -0
- csvql/exceptions.py +66 -0
- csvql/export.py +129 -0
- csvql/inspection.py +139 -0
- csvql/models.py +180 -0
- csvql/output.py +321 -0
- csvql/profiling.py +128 -0
- csvql/project_config.py +827 -0
- csvql/quality.py +179 -0
- csvql/query_workflow.py +164 -0
- csvql/release_readiness.py +237 -0
- csvql/source.py +86 -0
- csvql/source_resolver.py +55 -0
- csvql/sql_file.py +51 -0
- csvql/sql_utils.py +7 -0
- csvql/table_mapping.py +64 -0
- csvql/terminal_text.py +26 -0
- csvql/tui_app.py +2522 -0
- csvql/tui_editor.py +177 -0
- csvql/tui_help.py +49 -0
- csvql/tui_launcher.py +29 -0
- csvql/tui_native_picker.py +66 -0
- csvql/tui_result_store.py +88 -0
- csvql/tui_results.py +77 -0
- csvql/tui_sql_assist.py +589 -0
- csvql/tui_state.py +501 -0
- csvql/tui_workflows.py +554 -0
- localql-1.0.0.dist-info/METADATA +676 -0
- localql-1.0.0.dist-info/RECORD +42 -0
- localql-1.0.0.dist-info/WHEEL +4 -0
- localql-1.0.0.dist-info/entry_points.txt +2 -0
- localql-1.0.0.dist-info/licenses/LICENSE +21 -0
csvql/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""CSVQL public package interface."""
|
|
2
|
+
|
|
3
|
+
from csvql.api import CSVQLSession
|
|
4
|
+
from csvql.engine import CSVQLEngine
|
|
5
|
+
from csvql.export import ExportFormat
|
|
6
|
+
from csvql.models import InspectResult, ProfileResult, QueryResult, SampleResult, TableSource
|
|
7
|
+
from csvql.project_config import ProjectTablesResult
|
|
8
|
+
from csvql.quality import CheckRunResult
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CSVQLEngine",
|
|
12
|
+
"CSVQLSession",
|
|
13
|
+
"CheckRunResult",
|
|
14
|
+
"ExportFormat",
|
|
15
|
+
"InspectResult",
|
|
16
|
+
"ProfileResult",
|
|
17
|
+
"ProjectTablesResult",
|
|
18
|
+
"QueryResult",
|
|
19
|
+
"SampleResult",
|
|
20
|
+
"TableSource",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = "1.0.0"
|
csvql/__main__.py
ADDED
csvql/api.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Small public Python API for project-backed CSVQL workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from csvql.checks import run_configured_checks
|
|
9
|
+
from csvql.engine import CSVQLEngine
|
|
10
|
+
from csvql.exceptions import ExportError, ProjectConfigError
|
|
11
|
+
from csvql.export import (
|
|
12
|
+
ExportFormat,
|
|
13
|
+
format_query_result_for_export,
|
|
14
|
+
resolve_export_path,
|
|
15
|
+
write_export_file,
|
|
16
|
+
)
|
|
17
|
+
from csvql.inspection import inspect_csv_source, sample_csv_source
|
|
18
|
+
from csvql.models import InspectResult, ProfileResult, QueryResult, SampleResult
|
|
19
|
+
from csvql.profiling import profile_csv_source
|
|
20
|
+
from csvql.project_config import (
|
|
21
|
+
ProjectContext,
|
|
22
|
+
ProjectTable,
|
|
23
|
+
ProjectTablesResult,
|
|
24
|
+
build_project_tables_result,
|
|
25
|
+
load_project,
|
|
26
|
+
project_tables_to_sources,
|
|
27
|
+
resolve_catalog_path,
|
|
28
|
+
)
|
|
29
|
+
from csvql.quality import CheckRunResult
|
|
30
|
+
from csvql.source import CSVSource, source_from_path
|
|
31
|
+
from csvql.sql_file import load_sql_file
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class CSVQLSession:
|
|
36
|
+
"""Thin project-backed API over existing CSVQL services."""
|
|
37
|
+
|
|
38
|
+
_context: ProjectContext
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_config(cls, start_dir: str | Path = ".") -> CSVQLSession:
|
|
42
|
+
"""Create a session from the nearest project config at or above ``start_dir``."""
|
|
43
|
+
|
|
44
|
+
return cls(load_project(Path(start_dir)))
|
|
45
|
+
|
|
46
|
+
def tables(self) -> ProjectTablesResult:
|
|
47
|
+
"""Return the configured project table aliases and resolved paths."""
|
|
48
|
+
|
|
49
|
+
return build_project_tables_result(self._context)
|
|
50
|
+
|
|
51
|
+
def query(self, sql: str) -> QueryResult:
|
|
52
|
+
"""Run trusted local SQL against the configured project tables."""
|
|
53
|
+
|
|
54
|
+
with CSVQLEngine() as engine:
|
|
55
|
+
engine.register_tables(project_tables_to_sources(self._context))
|
|
56
|
+
return engine.query(sql)
|
|
57
|
+
|
|
58
|
+
def run_file(self, path: str | Path) -> QueryResult:
|
|
59
|
+
"""Load and run a saved SQL file resolved from the project root."""
|
|
60
|
+
|
|
61
|
+
sql_file = load_sql_file(str(path), base_dir=self._context.project_root)
|
|
62
|
+
return self.query(sql_file.sql)
|
|
63
|
+
|
|
64
|
+
def inspect(self, table: str, *, exact: bool = False) -> InspectResult:
|
|
65
|
+
"""Inspect a configured table alias."""
|
|
66
|
+
|
|
67
|
+
return inspect_csv_source(_catalog_source(self._context, table), exact=exact)
|
|
68
|
+
|
|
69
|
+
def sample(self, table: str, *, limit: int = 10) -> SampleResult:
|
|
70
|
+
"""Return a bounded sample from a configured table alias."""
|
|
71
|
+
|
|
72
|
+
return sample_csv_source(_catalog_source(self._context, table), limit=limit)
|
|
73
|
+
|
|
74
|
+
def profile(self, table: str) -> ProfileResult:
|
|
75
|
+
"""Profile a configured table alias."""
|
|
76
|
+
|
|
77
|
+
return profile_csv_source(_catalog_source(self._context, table))
|
|
78
|
+
|
|
79
|
+
def check(
|
|
80
|
+
self,
|
|
81
|
+
table: str | None = None,
|
|
82
|
+
*,
|
|
83
|
+
show_failures: bool = False,
|
|
84
|
+
failure_limit: int = 5,
|
|
85
|
+
) -> CheckRunResult:
|
|
86
|
+
"""Run configured data-quality checks for the project or one table alias."""
|
|
87
|
+
|
|
88
|
+
return run_configured_checks(
|
|
89
|
+
self._context,
|
|
90
|
+
table_name=table,
|
|
91
|
+
show_failures=show_failures,
|
|
92
|
+
failure_limit=failure_limit,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def export(
|
|
96
|
+
self,
|
|
97
|
+
sql_file: str | Path,
|
|
98
|
+
out: str | Path,
|
|
99
|
+
*,
|
|
100
|
+
format: ExportFormat | str = ExportFormat.json,
|
|
101
|
+
force: bool = False,
|
|
102
|
+
) -> Path:
|
|
103
|
+
"""Run a saved SQL file and export the result, defaulting to JSON output."""
|
|
104
|
+
|
|
105
|
+
export_format = _export_format(format)
|
|
106
|
+
output_path = resolve_export_path(
|
|
107
|
+
str(out),
|
|
108
|
+
base_dir=self._context.project_root,
|
|
109
|
+
force=force,
|
|
110
|
+
)
|
|
111
|
+
result = self.run_file(sql_file)
|
|
112
|
+
content = format_query_result_for_export(result, export_format)
|
|
113
|
+
write_export_file(output_path, content, overwrite=force)
|
|
114
|
+
return output_path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _catalog_source(context: ProjectContext, table_name: str) -> CSVSource:
|
|
118
|
+
project_table = _project_table(context, table_name)
|
|
119
|
+
resolved_path = resolve_catalog_path(project_table, context)
|
|
120
|
+
resolved_source = source_from_path(
|
|
121
|
+
str(resolved_path),
|
|
122
|
+
base_dir=context.project_root,
|
|
123
|
+
)
|
|
124
|
+
return CSVSource(
|
|
125
|
+
path=resolved_source.path,
|
|
126
|
+
display_path=table_name,
|
|
127
|
+
fingerprint=resolved_source.fingerprint,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _project_table(context: ProjectContext, table_name: str) -> ProjectTable:
|
|
132
|
+
normalized = table_name.strip().lower()
|
|
133
|
+
match = next(
|
|
134
|
+
(table for table in context.config.tables if table.name.lower() == normalized),
|
|
135
|
+
None,
|
|
136
|
+
)
|
|
137
|
+
if match is None:
|
|
138
|
+
raise ProjectConfigError(
|
|
139
|
+
f"Project catalog table '{table_name}' was not found in {context.config_path}.",
|
|
140
|
+
suggestion="Run csvql tables to list configured table aliases.",
|
|
141
|
+
)
|
|
142
|
+
return match
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _export_format(value: ExportFormat | str) -> ExportFormat:
|
|
146
|
+
if isinstance(value, ExportFormat):
|
|
147
|
+
return value
|
|
148
|
+
try:
|
|
149
|
+
return ExportFormat(value)
|
|
150
|
+
except ValueError as exc:
|
|
151
|
+
raise ExportError(
|
|
152
|
+
f"Unsupported export format: {value}",
|
|
153
|
+
suggestion="Use csv, json, or markdown.",
|
|
154
|
+
) from exc
|
csvql/atomic_write.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Atomic local text writes for CSVQL user-visible outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OperationCancelled(Exception):
|
|
12
|
+
"""Raised when a cancellable local operation is cancelled before commit."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OperationToken:
|
|
16
|
+
"""Thread-safe cancellation token for local TUI/file operations."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._cancelled = threading.Event()
|
|
20
|
+
|
|
21
|
+
def cancel(self) -> None:
|
|
22
|
+
"""Mark the operation as cancelled."""
|
|
23
|
+
|
|
24
|
+
self._cancelled.set()
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def is_cancelled(self) -> bool:
|
|
28
|
+
"""Return whether cancellation has been requested."""
|
|
29
|
+
|
|
30
|
+
return self._cancelled.is_set()
|
|
31
|
+
|
|
32
|
+
def raise_if_cancelled(self) -> None:
|
|
33
|
+
"""Raise :class:`OperationCancelled` when the token is cancelled."""
|
|
34
|
+
|
|
35
|
+
if self.is_cancelled:
|
|
36
|
+
raise OperationCancelled("Operation cancelled.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_text_atomic(
|
|
40
|
+
path: Path,
|
|
41
|
+
content: str,
|
|
42
|
+
*,
|
|
43
|
+
encoding: str = "utf-8",
|
|
44
|
+
newline: str | None = None,
|
|
45
|
+
overwrite: bool = True,
|
|
46
|
+
token: OperationToken | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Write text through a temp sibling file and atomically publish the target.
|
|
49
|
+
|
|
50
|
+
When ``overwrite`` is ``False``, the final path is only created if it does
|
|
51
|
+
not already exist.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
if token is not None:
|
|
55
|
+
token.raise_if_cancelled()
|
|
56
|
+
|
|
57
|
+
fd, temp_name = tempfile.mkstemp(
|
|
58
|
+
prefix=f".{path.name}.",
|
|
59
|
+
suffix=".tmp",
|
|
60
|
+
dir=path.parent,
|
|
61
|
+
text=True,
|
|
62
|
+
)
|
|
63
|
+
temp_path = Path(temp_name)
|
|
64
|
+
try:
|
|
65
|
+
with os.fdopen(fd, "w", encoding=encoding, newline=newline) as file:
|
|
66
|
+
file.write(content)
|
|
67
|
+
file.flush()
|
|
68
|
+
os.fsync(file.fileno())
|
|
69
|
+
if token is not None:
|
|
70
|
+
token.raise_if_cancelled()
|
|
71
|
+
if overwrite:
|
|
72
|
+
os.replace(temp_path, path)
|
|
73
|
+
else:
|
|
74
|
+
os.link(temp_path, path)
|
|
75
|
+
temp_path.unlink(missing_ok=True)
|
|
76
|
+
except BaseException:
|
|
77
|
+
try:
|
|
78
|
+
temp_path.unlink(missing_ok=True)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
raise
|
csvql/benchmark_data.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Deterministic synthetic sales-project generation for benchmarking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from csv import DictWriter
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import yaml # type: ignore[import-untyped]
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from csvql.benchmarking import BenchmarkDatasetRecord
|
|
16
|
+
|
|
17
|
+
CONFIG_TEXT = {
|
|
18
|
+
"version": 1,
|
|
19
|
+
"tables": {
|
|
20
|
+
"customers": {
|
|
21
|
+
"path": "data/customers.csv",
|
|
22
|
+
"checks": [
|
|
23
|
+
{"name": "customer_id_required", "type": "not_null", "column": "customer_id"},
|
|
24
|
+
{"name": "customer_id_unique", "type": "unique", "column": "customer_id"},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
"orders": {
|
|
28
|
+
"path": "data/orders.csv",
|
|
29
|
+
"checks": [
|
|
30
|
+
{"name": "order_id_required", "type": "not_null", "column": "order_id"},
|
|
31
|
+
{"name": "order_id_unique", "type": "unique", "column": "order_id"},
|
|
32
|
+
{
|
|
33
|
+
"name": "customer_exists",
|
|
34
|
+
"type": "foreign_key",
|
|
35
|
+
"column": "customer_id",
|
|
36
|
+
"references": {"table": "customers", "column": "customer_id"},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "total_amount_nonnegative",
|
|
40
|
+
"type": "min",
|
|
41
|
+
"column": "total_amount",
|
|
42
|
+
"value": 0,
|
|
43
|
+
},
|
|
44
|
+
{"name": "row_count_expected", "type": "row_count_between", "min": 1},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
REVENUE_BY_MONTH_SQL = """SELECT
|
|
51
|
+
date_trunc('month', order_date) AS order_month,
|
|
52
|
+
COUNT(*) AS order_count,
|
|
53
|
+
SUM(total_amount) AS revenue
|
|
54
|
+
FROM orders
|
|
55
|
+
GROUP BY 1
|
|
56
|
+
ORDER BY 1;
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
CUSTOMER_LTV_SQL = """SELECT
|
|
60
|
+
c.customer_id,
|
|
61
|
+
c.email,
|
|
62
|
+
COUNT(o.order_id) AS order_count,
|
|
63
|
+
SUM(o.total_amount) AS lifetime_value
|
|
64
|
+
FROM customers c
|
|
65
|
+
JOIN orders o USING (customer_id)
|
|
66
|
+
GROUP BY c.customer_id, c.email
|
|
67
|
+
ORDER BY lifetime_value DESC;
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class SyntheticSalesSpec:
|
|
73
|
+
"""Deterministic synthetic dataset sizing and seed inputs."""
|
|
74
|
+
|
|
75
|
+
dataset_id: str
|
|
76
|
+
seed: int
|
|
77
|
+
customer_count: int
|
|
78
|
+
orders_per_customer: int
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_synthetic_sales_project(root: Path, spec: SyntheticSalesSpec) -> None:
|
|
82
|
+
"""Write a complete benchmark project fixture under ``root``."""
|
|
83
|
+
|
|
84
|
+
rng = random.Random(spec.seed)
|
|
85
|
+
data_dir = root / "data"
|
|
86
|
+
query_dir = root / "queries"
|
|
87
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
query_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
|
|
90
|
+
customers_path = data_dir / "customers.csv"
|
|
91
|
+
orders_path = data_dir / "orders.csv"
|
|
92
|
+
|
|
93
|
+
with customers_path.open("w", encoding="utf-8", newline="") as handle:
|
|
94
|
+
writer = DictWriter(handle, fieldnames=["customer_id", "email", "created_at"])
|
|
95
|
+
writer.writeheader()
|
|
96
|
+
for index in range(1, spec.customer_count + 1):
|
|
97
|
+
writer.writerow(
|
|
98
|
+
{
|
|
99
|
+
"customer_id": f"CUST-{index:05d}",
|
|
100
|
+
"email": f"customer{index:05d}@example.com",
|
|
101
|
+
"created_at": (date(2025, 1, 1) + timedelta(days=index % 90)).isoformat(),
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
statuses = ("paid", "pending", "refunded")
|
|
106
|
+
with orders_path.open("w", encoding="utf-8", newline="") as handle:
|
|
107
|
+
writer = DictWriter(
|
|
108
|
+
handle,
|
|
109
|
+
fieldnames=["order_id", "customer_id", "order_date", "status", "total_amount"],
|
|
110
|
+
)
|
|
111
|
+
writer.writeheader()
|
|
112
|
+
order_index = 1
|
|
113
|
+
for customer_index in range(1, spec.customer_count + 1):
|
|
114
|
+
customer_id = f"CUST-{customer_index:05d}"
|
|
115
|
+
for _ in range(spec.orders_per_customer):
|
|
116
|
+
writer.writerow(
|
|
117
|
+
{
|
|
118
|
+
"order_id": f"ORD-{order_index:07d}",
|
|
119
|
+
"customer_id": customer_id,
|
|
120
|
+
"order_date": (
|
|
121
|
+
date(2025, 3, 1) + timedelta(days=order_index % 120)
|
|
122
|
+
).isoformat(),
|
|
123
|
+
"status": statuses[order_index % len(statuses)],
|
|
124
|
+
"total_amount": f"{rng.uniform(10, 500):.2f}",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
order_index += 1
|
|
128
|
+
|
|
129
|
+
(root / ".csvql.yml").write_text(
|
|
130
|
+
yaml.safe_dump(CONFIG_TEXT, sort_keys=False),
|
|
131
|
+
encoding="utf-8",
|
|
132
|
+
)
|
|
133
|
+
(query_dir / "revenue_by_month.sql").write_text(REVENUE_BY_MONTH_SQL, encoding="utf-8")
|
|
134
|
+
(query_dir / "customer_ltv.sql").write_text(CUSTOMER_LTV_SQL, encoding="utf-8")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def describe_sales_project(
|
|
138
|
+
root: Path,
|
|
139
|
+
*,
|
|
140
|
+
dataset_id: str,
|
|
141
|
+
seed: int | None,
|
|
142
|
+
project_path: str,
|
|
143
|
+
) -> BenchmarkDatasetRecord:
|
|
144
|
+
"""Return row-count and file-size facts for one generated project."""
|
|
145
|
+
|
|
146
|
+
from csvql.benchmarking import BenchmarkDatasetRecord
|
|
147
|
+
|
|
148
|
+
customers_path = root / "data" / "customers.csv"
|
|
149
|
+
orders_path = root / "data" / "orders.csv"
|
|
150
|
+
return BenchmarkDatasetRecord(
|
|
151
|
+
dataset_id=dataset_id,
|
|
152
|
+
project_path=project_path,
|
|
153
|
+
seed=seed,
|
|
154
|
+
customer_rows=_csv_row_count(customers_path),
|
|
155
|
+
order_rows=_csv_row_count(orders_path),
|
|
156
|
+
file_sizes={
|
|
157
|
+
"data/customers.csv": customers_path.stat().st_size,
|
|
158
|
+
"data/orders.csv": orders_path.stat().st_size,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _csv_row_count(path: Path) -> int:
|
|
164
|
+
"""Return the number of data rows in a CSV file with one header row."""
|
|
165
|
+
|
|
166
|
+
with path.open(encoding="utf-8") as handle:
|
|
167
|
+
return max(sum(1 for _ in handle) - 1, 0)
|