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 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
@@ -0,0 +1,6 @@
1
+ """Run CSVQL as `python -m csvql`."""
2
+
3
+ from csvql.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
@@ -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)