pyursa 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.
- pyursa/__init__.py +88 -0
- pyursa/backends/__init__.py +28 -0
- pyursa/backends/base.py +169 -0
- pyursa/backends/castrules.py +22 -0
- pyursa/backends/duckdb_backend.py +488 -0
- pyursa/backends/polars_backend.py +353 -0
- pyursa/errors.py +125 -0
- pyursa/expr.py +792 -0
- pyursa/frame.py +460 -0
- pyursa/mcp_server.py +219 -0
- pyursa/parse.py +207 -0
- pyursa/plan.py +615 -0
- pyursa/providers.py +882 -0
- pyursa/py.typed +0 -0
- pyursa/redact.py +54 -0
- pyursa/semantic.py +571 -0
- pyursa/summarize.py +211 -0
- pyursa/tools.py +414 -0
- pyursa/types.py +447 -0
- pyursa-1.0.0.dist-info/METADATA +140 -0
- pyursa-1.0.0.dist-info/RECORD +25 -0
- pyursa-1.0.0.dist-info/WHEEL +4 -0
- pyursa-1.0.0.dist-info/entry_points.txt +2 -0
- pyursa-1.0.0.dist-info/licenses/LICENSE +202 -0
- pyursa-1.0.0.dist-info/licenses/NOTICE +4 -0
pyursa/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""pyursa: an engine-agnostic dataframe library for agentic AI workflows.
|
|
2
|
+
|
|
3
|
+
import pyursa as pu
|
|
4
|
+
from pyursa import col
|
|
5
|
+
|
|
6
|
+
df = pu.from_dict({"region": ["east", "west"], "sales": [10, 20]})
|
|
7
|
+
out = df.filter(col("sales") > 5, intent="drop tiny orders").group_by("region").agg(col("sales").sum())
|
|
8
|
+
out.collect(backend="duckdb")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .backends import (
|
|
12
|
+
SEMANTICS,
|
|
13
|
+
available_backends,
|
|
14
|
+
default_backend,
|
|
15
|
+
get_backend,
|
|
16
|
+
register_backend,
|
|
17
|
+
set_default_backend,
|
|
18
|
+
)
|
|
19
|
+
from .errors import (
|
|
20
|
+
BackendError,
|
|
21
|
+
ColumnNotFoundError,
|
|
22
|
+
DuplicateColumnError,
|
|
23
|
+
HistoryError,
|
|
24
|
+
InvalidOperationError,
|
|
25
|
+
PyursaError,
|
|
26
|
+
TypeMismatchError,
|
|
27
|
+
UnknownBackendError,
|
|
28
|
+
UnsupportedTypeError,
|
|
29
|
+
)
|
|
30
|
+
from .expr import coalesce, col, lit, rank, row_number, when
|
|
31
|
+
from .expr import len_ as len # noqa: A001 (pu.len() mirrors Polars)
|
|
32
|
+
from .frame import (
|
|
33
|
+
Frame,
|
|
34
|
+
concat,
|
|
35
|
+
from_arrow,
|
|
36
|
+
from_dict,
|
|
37
|
+
from_pandas,
|
|
38
|
+
from_polars,
|
|
39
|
+
read_csv,
|
|
40
|
+
read_json,
|
|
41
|
+
read_parquet,
|
|
42
|
+
)
|
|
43
|
+
from .providers import (
|
|
44
|
+
PROVIDERS,
|
|
45
|
+
BedrockClient,
|
|
46
|
+
LLMProviderError,
|
|
47
|
+
OpenAICompatibleClient,
|
|
48
|
+
list_providers,
|
|
49
|
+
llm_client,
|
|
50
|
+
print_providers,
|
|
51
|
+
)
|
|
52
|
+
from .semantic import (
|
|
53
|
+
AnthropicClient,
|
|
54
|
+
FunctionClient,
|
|
55
|
+
LLMBudgetError,
|
|
56
|
+
LLMClient,
|
|
57
|
+
LLMNotConfiguredError,
|
|
58
|
+
PromptClient,
|
|
59
|
+
llm_reports,
|
|
60
|
+
set_llm,
|
|
61
|
+
)
|
|
62
|
+
from .types import (
|
|
63
|
+
Boolean,
|
|
64
|
+
Date,
|
|
65
|
+
Datetime,
|
|
66
|
+
Decimal,
|
|
67
|
+
DType,
|
|
68
|
+
Float64,
|
|
69
|
+
Int64,
|
|
70
|
+
List,
|
|
71
|
+
Null,
|
|
72
|
+
String,
|
|
73
|
+
Struct,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
__version__ = "1.0.0"
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"Frame", "col", "lit", "len", "when", "coalesce", "row_number", "rank", "concat",
|
|
80
|
+
"from_arrow", "from_dict", "from_pandas", "from_polars", "read_csv", "read_json", "read_parquet",
|
|
81
|
+
"DType", "Int64", "Float64", "String", "Boolean", "Date", "Datetime", "Decimal", "List", "Struct", "Null",
|
|
82
|
+
"SEMANTICS", "available_backends", "default_backend", "get_backend", "register_backend", "set_default_backend",
|
|
83
|
+
"set_llm", "llm_reports", "LLMClient", "PromptClient", "FunctionClient", "AnthropicClient",
|
|
84
|
+
"LLMBudgetError", "LLMNotConfiguredError", "LLMProviderError",
|
|
85
|
+
"llm_client", "list_providers", "print_providers", "OpenAICompatibleClient", "BedrockClient", "PROVIDERS",
|
|
86
|
+
"PyursaError", "BackendError", "ColumnNotFoundError", "DuplicateColumnError", "HistoryError",
|
|
87
|
+
"InvalidOperationError", "TypeMismatchError", "UnknownBackendError", "UnsupportedTypeError",
|
|
88
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .base import (
|
|
2
|
+
SEMANTICS,
|
|
3
|
+
Backend,
|
|
4
|
+
available_backends,
|
|
5
|
+
default_backend,
|
|
6
|
+
get_backend,
|
|
7
|
+
register_backend,
|
|
8
|
+
set_default_backend,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _polars():
|
|
13
|
+
from .polars_backend import PolarsBackend
|
|
14
|
+
return PolarsBackend()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _duckdb():
|
|
18
|
+
from .duckdb_backend import DuckDBBackend
|
|
19
|
+
return DuckDBBackend()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
register_backend("polars", _polars)
|
|
23
|
+
register_backend("duckdb", _duckdb)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"SEMANTICS", "Backend", "available_backends", "default_backend",
|
|
27
|
+
"get_backend", "register_backend", "set_default_backend",
|
|
28
|
+
]
|
pyursa/backends/base.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Backend protocol and registry.
|
|
2
|
+
|
|
3
|
+
A backend compiles a logical plan to its engine and returns Arrow. The
|
|
4
|
+
library casts every result to the plan's declared schema, so all backends
|
|
5
|
+
return identically-typed data. Behavioral rules every backend must follow
|
|
6
|
+
live in SEMANTICS and are enforced by tests/test_conformance.py.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
import pyarrow as pa
|
|
15
|
+
|
|
16
|
+
from ..errors import BackendError, UnknownBackendError
|
|
17
|
+
from ..plan import Node
|
|
18
|
+
from ..types import cast_column, to_arrow_schema
|
|
19
|
+
|
|
20
|
+
SEMANTICS = {
|
|
21
|
+
"null_comparison": "Comparisons with null yield null; filter() drops rows where the condition is null.",
|
|
22
|
+
"division": "'/' always returns Float64 and follows IEEE 754 (x/0 = inf, 0/0 = NaN).",
|
|
23
|
+
"sum_of_nulls": "sum() over only nulls (or no rows) returns null, not 0.",
|
|
24
|
+
"n_unique": "n_unique() does not count null as a value.",
|
|
25
|
+
"count": "count() counts non-null values; pu.len() counts rows.",
|
|
26
|
+
"sort": "sort() is stable and places nulls last in both directions.",
|
|
27
|
+
"group_by_order": "group_by().agg() output is sorted by the keys ascending, nulls last.",
|
|
28
|
+
"join_order": "join() keeps left row order, then right row order within matches; full joins put unmatched right rows last; null keys never match (anti joins keep null-key rows).",
|
|
29
|
+
"distinct": "distinct() keeps the first occurrence of each row (or subset) in input order; nulls compare equal.",
|
|
30
|
+
"concat": "concat() returns all rows of the first frame, then the second, and so on.",
|
|
31
|
+
"rounding": "round() rounds half away from zero (2.5 -> 3, -2.5 -> -3).",
|
|
32
|
+
"decimals": "Decimal +,-,* are exact (scale: max for +/-, sum for *); / returns Float64; sum keeps the scale; mean/median/std return Float64.",
|
|
33
|
+
"quantiles": "median()/quantile(q) use linear interpolation; std()/var() are sample statistics (ddof=1).",
|
|
34
|
+
"windows": "Window results keep input row order; ties in order_by keep input order; rank() is SQL RANK with nulls last; cum_sum skips nulls.",
|
|
35
|
+
"time_zones": "Datetime(tz) keeps its zone; .dt parts are computed in that zone; comparing different zones is an error.",
|
|
36
|
+
"strings": "str.len counts characters; str.strip removes spaces, tabs and newlines; str.slice is 0-based.",
|
|
37
|
+
"row_order": "filter/select/with_columns/limit preserve input row order.",
|
|
38
|
+
"nan": "NaN is the largest float value and equals itself (in comparisons, sorting, grouping and min/max); max() returns NaN when one is present; std/var with an infinite value return NaN.",
|
|
39
|
+
"integer_overflow": "Int64 arithmetic, sums and running totals that leave the Int64 range raise INTEGER_OVERFLOW; they never wrap around.",
|
|
40
|
+
"casts": "Casts are strict and identical everywhere: text is trimmed of spaces/tabs/newlines and must then be a valid literal (integers like '+12', floats like '1e3', 'inf' or 'nan', decimals with up to 20 integer digits and 17 places, booleans 'true'/'false'/'1'/'0', dates 'YYYY-MM-DD', datetimes 'YYYY-MM-DD HH:MM[:SS[.ffffff]]' with ' ' or 'T', years 0001-9999, hours 00-23, no leap seconds); anything else raises CAST_FAILED. Float or Decimal to Int64 requires a whole number. Conversions to Decimal round half away from zero. Floats print as text that converts back to the exact same value ('NaN', 'inf'; notation such as '0.00001' vs '1e-5' can differ between engines); datetimes print as 'YYYY-MM-DD HH:MM:SS[.ffffff]'. Casts that have no single meaning (dates to numbers, floats to Boolean, changing a time zone) are rejected when the plan is built.",
|
|
41
|
+
"names": "Column names must differ by more than letter case, must not contain NUL, and must not start with the reserved prefix '__pu_'.",
|
|
42
|
+
"float_precision": "Float sums and means can differ in the last digits (about 1e-15 relative) between engines because they add in different orders; every other result is identical.",
|
|
43
|
+
"known_differences": "Rare cases engines still differ on, by design of the engines: upper()/lower() of a few special characters (e.g. 'ß', 'İ'), and DuckDB may return -0.0 as 0.0 after sorting.",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Backend(ABC):
|
|
48
|
+
name: str = "base"
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def _run(self, node: Node) -> pa.Table: ...
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def compile(self, node: Node) -> str:
|
|
55
|
+
"""Human-readable compiled form (SQL text, engine plan, ...) for explain()."""
|
|
56
|
+
|
|
57
|
+
def execute(self, node: Node) -> pa.Table:
|
|
58
|
+
try:
|
|
59
|
+
table = self._run(node)
|
|
60
|
+
except (KeyboardInterrupt, SystemExit, GeneratorExit):
|
|
61
|
+
raise
|
|
62
|
+
except BaseException as exc: # engine failures (including Rust panics) become structured errors
|
|
63
|
+
raise engine_error(self, node, exc) from None
|
|
64
|
+
return conform(table, node, self.name)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def engine_error(backend: "Backend", node: Node, exc: BaseException) -> BackendError:
|
|
68
|
+
from .castrules import SENTINEL_PREFIX
|
|
69
|
+
text = f"{type(exc).__name__}: {exc}"
|
|
70
|
+
try:
|
|
71
|
+
query = backend.compile(node)
|
|
72
|
+
except BaseException:
|
|
73
|
+
query = None
|
|
74
|
+
details = {"backend": backend.name, "engine_error": text[:2000], "compiled": query}
|
|
75
|
+
# classify on the first line only: later lines echo expressions and column names
|
|
76
|
+
low = text.splitlines()[0].lower() if text else ""
|
|
77
|
+
if SENTINEL_PREFIX.lower() in low:
|
|
78
|
+
target = low.split(SENTINEL_PREFIX.lower(), 1)[1].split("'")[0].split('"')[0].split("]")[0].split()[0]
|
|
79
|
+
return BackendError(
|
|
80
|
+
f"Some values can't be converted to {target} under the strict cast rules.",
|
|
81
|
+
details=details, code="CAST_FAILED",
|
|
82
|
+
suggestions=[{"description": "Text must match the target format exactly after trimming spaces "
|
|
83
|
+
"(see pu.SEMANTICS['casts']). Filter or clean invalid values first, e.g. with "
|
|
84
|
+
".str.contains_regex(...) and when/then."}],
|
|
85
|
+
)
|
|
86
|
+
if ("f64" in low and "i64" in low) or "type double with value" in low:
|
|
87
|
+
return BackendError(
|
|
88
|
+
"A floating-point value isn't a whole number (or is NaN/inf), so it can't become Int64.",
|
|
89
|
+
details=details, code="CAST_FAILED",
|
|
90
|
+
suggestions=[{"description": "Round first.", "fix": 'col("x").round(0).cast("Int64")'}],
|
|
91
|
+
)
|
|
92
|
+
if ("overflow" in low or ("i128" in low and "i64" in low)
|
|
93
|
+
or ("out of range" in low and any(k in low for k in ("int64", "bigint", "integer", "hugeint")))):
|
|
94
|
+
return BackendError(
|
|
95
|
+
"An integer result is outside the Int64 range.", details=details, code="INTEGER_OVERFLOW",
|
|
96
|
+
suggestions=[{"description": "Cast to Decimal(38,0) or Float64 before the calculation.",
|
|
97
|
+
"fix": 'col("x").cast("Decimal(38,0)")'}],
|
|
98
|
+
)
|
|
99
|
+
if any(k in low for k in ("conversion error", "could not convert", "conversion from", "invalid date",
|
|
100
|
+
"cannot parse", "could not parse", "failed to parse")) or (
|
|
101
|
+
"out of range" in low and any(k in low for k in ("date", "time", "decimal"))):
|
|
102
|
+
return BackendError(
|
|
103
|
+
"Some values can't be converted to the target type.", details=details, code="CAST_FAILED",
|
|
104
|
+
suggestions=[{"description": "Check the values (e.g. impossible dates such as 2024-02-30) and clean "
|
|
105
|
+
"or filter them before casting."}],
|
|
106
|
+
)
|
|
107
|
+
return BackendError(
|
|
108
|
+
f"The {backend.name} engine failed while executing the plan: {exc}",
|
|
109
|
+
details=details,
|
|
110
|
+
suggestions=[{"description": "Check casts and value ranges; frame.explain() shows the compiled query."}],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def conform(table: pa.Table, node: Node, backend: str) -> pa.Table:
|
|
115
|
+
target = to_arrow_schema(node.schema)
|
|
116
|
+
if table.column_names != list(node.schema):
|
|
117
|
+
raise BackendError(
|
|
118
|
+
f"Backend '{backend}' returned columns {table.column_names}, expected {list(node.schema)}.",
|
|
119
|
+
details={"backend": backend}, code="BACKEND_CONTRACT_VIOLATION",
|
|
120
|
+
)
|
|
121
|
+
cols = []
|
|
122
|
+
for field, column in zip(target, table.columns):
|
|
123
|
+
if pa.types.is_null(field.type): # engines may type all-null results as int32 etc.
|
|
124
|
+
if column.null_count != len(column):
|
|
125
|
+
raise BackendError(f"Backend '{backend}' returned values for Null column '{field.name}'.",
|
|
126
|
+
code="BACKEND_CONTRACT_VIOLATION")
|
|
127
|
+
cols.append(pa.nulls(len(column)))
|
|
128
|
+
else:
|
|
129
|
+
cols.append(cast_column(column, field.type, field.name))
|
|
130
|
+
return pa.Table.from_arrays(cols, schema=target)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_REGISTRY: dict[str, Callable[[], Backend]] = {}
|
|
134
|
+
_INSTANCES: dict[str, Backend] = {}
|
|
135
|
+
_DEFAULT = ["polars"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def register_backend(name: str, factory: Callable[[], Backend]) -> None:
|
|
139
|
+
_REGISTRY[name] = factory
|
|
140
|
+
_INSTANCES.pop(name, None)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def available_backends() -> list[str]:
|
|
144
|
+
return sorted(_REGISTRY)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_backend(name: str | None = None) -> Backend:
|
|
148
|
+
name = name or _DEFAULT[0]
|
|
149
|
+
if name not in _REGISTRY:
|
|
150
|
+
raise UnknownBackendError(
|
|
151
|
+
f"Unknown backend '{name}'.",
|
|
152
|
+
details={"requested": name, "available": available_backends()},
|
|
153
|
+
suggestions=[{"description": f"Use one of: {', '.join(available_backends())}."}],
|
|
154
|
+
)
|
|
155
|
+
if name not in _INSTANCES:
|
|
156
|
+
_INSTANCES[name] = _REGISTRY[name]()
|
|
157
|
+
return _INSTANCES[name]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def set_default_backend(name: str) -> None:
|
|
161
|
+
if not isinstance(name, str) or not name:
|
|
162
|
+
raise UnknownBackendError("set_default_backend() needs a backend name.",
|
|
163
|
+
details={"available": available_backends()})
|
|
164
|
+
get_backend(name)
|
|
165
|
+
_DEFAULT[0] = name
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def default_backend() -> str:
|
|
169
|
+
return _DEFAULT[0]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Patterns for the strict cast contract (SEMANTICS['casts']), shared by every backend.
|
|
2
|
+
|
|
3
|
+
Text is trimmed of spaces, tabs and newlines first. A value that doesn't match its
|
|
4
|
+
pattern is replaced by a sentinel string that the engine then fails to convert, so
|
|
5
|
+
invalid input raises an error instead of becoming null or a guessed value."""
|
|
6
|
+
|
|
7
|
+
INT_RE = r"^[+-]?[0-9]+$"
|
|
8
|
+
FLOAT_RE = r"^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$"
|
|
9
|
+
# up to 20 integer digits and 17 decimal places, so rounding to any scale never overflows Decimal(38, 17)
|
|
10
|
+
DECIMAL_RE = r"^[+-]?([0-9]{1,20}\.?[0-9]{0,17}|\.[0-9]{1,17})$"
|
|
11
|
+
_YEAR = r"([0-9]{3}[1-9]|[0-9]{2}[1-9][0-9]|[0-9][1-9][0-9]{2}|[1-9][0-9]{3})" # 0001-9999
|
|
12
|
+
DATE_RE = r"^" + _YEAR + r"-[0-9]{2}-[0-9]{2}$"
|
|
13
|
+
# hours 00-23, minutes and seconds 00-59 (no 24:00, no leap seconds: engines disagree on both)
|
|
14
|
+
DATETIME_RE = (r"^" + _YEAR + r"-[0-9]{2}-[0-9]{2}[ T]([01][0-9]|2[0-3]):[0-5][0-9]"
|
|
15
|
+
r"(:[0-5][0-9](\.[0-9]{1,6})?)?$")
|
|
16
|
+
BOOL_TRUE = ["true", "1"]
|
|
17
|
+
BOOL_FALSE = ["false", "0"]
|
|
18
|
+
SENTINEL_PREFIX = "PYURSA_INVALID_"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sentinel(to) -> str:
|
|
22
|
+
return f"{SENTINEL_PREFIX}{str(to).replace('(', '_').replace(')', '').replace(',', '_')}"
|