codestr 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.
codestr/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from .engine import CodeStr as CodeStr
2
+ from .syntax import (
3
+ Call as Call,
4
+ )
5
+ from .syntax import (
6
+ Column as Column,
7
+ )
8
+ from .syntax import (
9
+ ExprNode as ExprNode,
10
+ )
11
+ from .syntax import (
12
+ Literal as Literal,
13
+ )
14
+
15
+ __all__ = ["Call", "CodeStr", "Column", "ExprNode", "Literal"]
codestr/compiler.py ADDED
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+
5
+ import polars as pl
6
+
7
+ from codestr.errors import CompileError
8
+ from codestr.syntax import Call, Column, ExprNode, Literal
9
+ from codestr.udf.registry import UDFRegistry
10
+
11
+ # Canonical window defaults for when the caller does not provide ts_over/cs_over.
12
+ # These match the default time_col="datetime", asset_col="asset" convention.
13
+ # When called through the CodeStr engine, these are ALWAYS overridden by the
14
+ # engine's per-instance _ts_over / _cs_over (derived from time_col/asset_col).
15
+ _TS_DEFAULT_OVER = {"partition_by": ["asset"], "order_by": ["datetime"]}
16
+ _CS_DEFAULT_OVER = {"partition_by": ["datetime"], "order_by": ["asset"]}
17
+
18
+
19
+ def compile(
20
+ node: ExprNode,
21
+ registry: UDFRegistry | None = None,
22
+ dims: list[int] | None = None,
23
+ ts_over: dict[str, list[str]] | None = None,
24
+ cs_over: dict[str, list[str]] | None = None,
25
+ ) -> pl.Expr:
26
+ """Compile an AST node to a Polars expression.
27
+
28
+ Pure function — no side effects, no state mutation.
29
+
30
+ Args:
31
+ node: The root AST node to compile.
32
+ registry: UDF registry for function lookup. Uses the global singleton by default.
33
+ dims: Dimension info (e.g. [num_datetimes, num_assets]) for operators that need
34
+ array reshaping context.
35
+ ts_over: Window config for time-series operators
36
+ (``{"partition_by": [...], "order_by": [...]}``).
37
+ cs_over: Window config for cross-section operators.
38
+
39
+ Returns:
40
+ A Polars expression with the node's alias applied.
41
+ """
42
+ if registry is None:
43
+ registry = UDFRegistry.get_instance()
44
+ return _compile(node, registry, dims, ts_over, cs_over).alias(node.alias)
45
+
46
+
47
+ def _resolve(
48
+ node: ExprNode,
49
+ registry: UDFRegistry,
50
+ dims: list[int] | None,
51
+ ts_over: dict[str, list[str]] | None = None,
52
+ cs_over: dict[str, list[str]] | None = None,
53
+ ) -> pl.Expr | int | float | str:
54
+ """Compile an AST node to a Polars expression or resolve to a Python scalar.
55
+
56
+ Literal nodes return bare Python values (int/float), Column and Call nodes
57
+ return pl.Expr. This is used so that UDF functions receive appropriate types
58
+ for positional and keyword arguments.
59
+ """
60
+ if isinstance(node, Column):
61
+ return pl.col(node.name)
62
+ if isinstance(node, Literal):
63
+ return node.value
64
+ if isinstance(node, Call):
65
+ return _compile(node, registry, dims, ts_over, cs_over)
66
+ raise TypeError(f"Unknown node type: {type(node)}")
67
+
68
+
69
+ def _compile(
70
+ node: ExprNode,
71
+ registry: UDFRegistry,
72
+ dims: list[int] | None = None,
73
+ ts_over: dict[str, list[str]] | None = None,
74
+ cs_over: dict[str, list[str]] | None = None,
75
+ ) -> pl.Expr:
76
+ """Recursively compile an AST node to a Polars expression.
77
+
78
+ Column → pl.col, Literal → pl.lit, Call → UDF invocation.
79
+
80
+ Engine context is injected automatically by inspecting the UDF signature:
81
+ * ``dims`` — injected if the function accepts it and dims is available.
82
+ * ``partition_by`` / ``order_by`` — injected based on the UDF category
83
+ (``"ts"`` → ts_over, ``"cs"`` → cs_over), only if the function
84
+ signature includes those parameter names.
85
+ """
86
+ from toolz import partial
87
+
88
+ if isinstance(node, Column):
89
+ return pl.col(node.name)
90
+
91
+ if isinstance(node, Literal):
92
+ return pl.lit(node.value)
93
+
94
+ if isinstance(node, Call):
95
+ if node.fn_name not in registry:
96
+ raise CompileError(f"Unknown function: {node.fn_name}")
97
+
98
+ meta = registry[node.fn_name]
99
+ func = meta.fn
100
+
101
+ args: list = []
102
+ kwargs: dict = {}
103
+
104
+ sig_params = list(inspect.signature(func).parameters.keys())
105
+
106
+ # Inject dims if the operator accepts it
107
+ if "dims" in sig_params and dims is not None:
108
+ func = partial(func, dims=dims)
109
+
110
+ # Inject over config based on UDF category
111
+ _inject_over(meta.category, sig_params, ts_over, cs_over, kwargs)
112
+
113
+ for arg in node.args:
114
+ if isinstance(arg, dict):
115
+ for k, v in arg.items():
116
+ kwargs[k] = (
117
+ _resolve(v, registry, dims, ts_over, cs_over)
118
+ if isinstance(v, ExprNode)
119
+ else v
120
+ )
121
+ else:
122
+ args.append(_resolve(arg, registry, dims, ts_over, cs_over))
123
+
124
+ try:
125
+ return func(*args, **kwargs)
126
+ except Exception as e:
127
+ raise CompileError(
128
+ f"{node.fn_name}({', '.join(str(a) for a in node.args)})\n{e}"
129
+ ) from e
130
+
131
+ raise TypeError(f"Unknown node type: {type(node)}")
132
+
133
+
134
+ def _inject_over(
135
+ category: str,
136
+ sig_params: list[str],
137
+ ts_over: dict[str, list[str]] | None,
138
+ cs_over: dict[str, list[str]] | None,
139
+ kwargs: dict,
140
+ ) -> None:
141
+ """Inject partition_by/order_by into kwargs based on operator category.
142
+
143
+ TS/CS operators ALWAYS receive window config. The priority is:
144
+ 1. Caller-provided ts_over/cs_over (from the engine)
145
+ 2. Module-level canonical defaults (_TS_DEFAULT_OVER / _CS_DEFAULT_OVER)
146
+
147
+ Math/user operators have no window concept and are skipped.
148
+ """
149
+ if category == "ts":
150
+ over_config = ts_over if ts_over is not None else _TS_DEFAULT_OVER
151
+ elif category == "cs":
152
+ over_config = cs_over if cs_over is not None else _CS_DEFAULT_OVER
153
+ else:
154
+ return # "math" and "user" have no window config
155
+
156
+ if "partition_by" in sig_params:
157
+ kwargs["partition_by"] = over_config["partition_by"]
158
+ if "order_by" in sig_params:
159
+ kwargs["order_by"] = over_config["order_by"]
codestr/engine.py ADDED
@@ -0,0 +1,364 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import polars as pl
6
+ from loguru import logger
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Callable
10
+
11
+ from codestr.compiler import compile as _pure_compile
12
+ from codestr.errors import CompileError, FailError, PolarsError
13
+ from codestr.parser import parse as _parse
14
+ from codestr.syntax import (
15
+ Call,
16
+ )
17
+ from codestr.syntax import (
18
+ depth as _depth,
19
+ )
20
+ from codestr.syntax import (
21
+ node_count as _node_count,
22
+ )
23
+ from codestr.syntax import (
24
+ to_rpn as _to_rpn,
25
+ )
26
+ from codestr.udf.registry import UDFRegistry
27
+
28
+
29
+ class CodeStr:
30
+ """Expression compute engine with DSL → Polars translation.
31
+
32
+ State invariants
33
+ ----------------
34
+ - ``data`` : the most recently materialised DataFrame. Only ``None`` before
35
+ the first ``sql()`` call on a ``pure_lazy`` engine.
36
+ - ``_data_`` : lazy compute graph that accumulates ``with_columns`` during
37
+ a single ``sql()`` call. Reset to ``None`` on ``clear_cache()``.
38
+ - ``_last_query_cache`` : result of the last eager ``sql()``, used to merge
39
+ new columns back into ``data`` on the next call.
40
+ - ``_expr_cache`` : persistent cross-query cache (ExprNode → alias).
41
+ - ``_cur_expr_cache`` : per-query cache, merged into ``_expr_cache`` after
42
+ a successful eager ``sql()``.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ data: pl.LazyFrame | pl.DataFrame,
48
+ index: tuple[str, str] = ("datetime", "asset"),
49
+ partition_by: list[str] | None = None,
50
+ order_by: list[str] | None = None,
51
+ align: bool = True,
52
+ pure_lazy: bool = False,
53
+ ):
54
+ """Initialize the CodeStr engine.
55
+
56
+ Args:
57
+ data: Input Polars DataFrame or LazyFrame.
58
+ index: A 2-tuple ``(time_col, entity_col)`` used for panel alignment
59
+ and result column selection.
60
+ partition_by: Entity-axis columns for window grouping.
61
+ Defaults to ``[index[1]]`` (e.g. ``["asset"]``).
62
+ order_by: Time-axis columns for window ordering.
63
+ Defaults to ``[index[0]]`` (e.g. ``["datetime"]``).
64
+ align: If True (default), perform cross-join alignment to fill
65
+ missing index combinations with nulls.
66
+ pure_lazy: If True, never materialize data — keep everything as
67
+ LazyFrame. ``sql()`` calls will not update ``self.data``.
68
+
69
+ Window Semantics
70
+ ----------------
71
+ * **TS (time-series)** — ``over(partition_by=partition_by, order_by=order_by)``.
72
+ Each entity's rolling window runs independently along the time axis.
73
+ * **CS (cross-section)** — ``over(partition_by=order_by, order_by=partition_by)``.
74
+ At each time slice, operators compute across all entities.
75
+ """
76
+ assert isinstance(data, (pl.LazyFrame, pl.DataFrame)), (
77
+ "data 必须是 polars DataFrame 或 LazyFrame"
78
+ )
79
+ self.failed: list = []
80
+ self._expr_cache: dict = {}
81
+ self._cur_expr_cache: dict = {}
82
+
83
+ self.data: pl.DataFrame | None = None
84
+ self.index: tuple[str, str] = index
85
+ self._data_: pl.LazyFrame | None = None
86
+ self._last_query_cache: pl.DataFrame | None = None
87
+
88
+ # Over-window config: user provides partition / order columns explicitly
89
+ _partition = partition_by if partition_by is not None else [self.index[1]]
90
+ _order = order_by if order_by is not None else [self.index[0]]
91
+
92
+ self._ts_over = {
93
+ "partition_by": _partition, # entity columns
94
+ "order_by": _order, # time columns
95
+ }
96
+ self._cs_over = {
97
+ "partition_by": _order, # time columns (swapped)
98
+ "order_by": _partition, # entity columns (swapped)
99
+ }
100
+
101
+ if pure_lazy:
102
+ self._data_ = data
103
+ else:
104
+ self.data = data.with_columns(pl.col(pl.Decimal).cast(float))
105
+ if isinstance(self.data, pl.LazyFrame):
106
+ self.data = self.data.collect()
107
+
108
+ if align:
109
+ self.align()
110
+
111
+ def align(self, on=None):
112
+ """数据对齐
113
+
114
+ Args:
115
+ on: 2-tuple of columns to align on. Defaults to ``self.index``.
116
+ """
117
+ if on is None:
118
+ on = (self.index[0], self.index[1])
119
+ lev_vals: list[pl.DataFrame] = [self.data.select(name).drop_nulls().unique() for name in on]
120
+ full_index = lev_vals[0].unique()
121
+ for lev_val in lev_vals[1:]:
122
+ full_index = full_index.join(lev_val.unique(), how="cross")
123
+ self.data = full_index.join(self.data, on=on, how="left").sort(self.index)
124
+
125
+ self.dims = [self.data[name].drop_nulls().n_unique() for name in on]
126
+
127
+ @property
128
+ def cache_columns(self) -> list[str]:
129
+ """Currently available column names (from materialised data or lazy graph)."""
130
+ if self.data is not None:
131
+ return self.data.columns
132
+ if self._data_ is not None:
133
+ return self._data_.collect_schema().names()
134
+ return []
135
+
136
+ def __str__(self):
137
+ return str(self.data)
138
+
139
+ def __repr__(self):
140
+ return str(self.data)
141
+
142
+ def register_udf(self, func: Callable, name: str | None = None):
143
+ """Register a user-defined function into the UDF registry."""
144
+ from codestr.udf.registry import UDFMeta, UDFRegistry
145
+
146
+ UDFRegistry.get_instance().register(
147
+ UDFMeta(
148
+ name=name if name is not None else func.__name__,
149
+ fn=func,
150
+ category="user",
151
+ )
152
+ )
153
+
154
+ def check_expr(
155
+ self,
156
+ expr: str,
157
+ max_depth: int | None = None,
158
+ max_nodes: int | None = None,
159
+ check_rpn: bool = True,
160
+ check_redundant: bool = True,
161
+ ):
162
+ """Validate an expression string before execution.
163
+
164
+ Does NOT execute the expression — only parses and runs structural checks.
165
+
166
+ Args:
167
+ expr: The DSL expression string to validate.
168
+ max_depth: If set, reject expressions exceeding this AST depth.
169
+ max_nodes: If set, reject expressions exceeding this node count.
170
+ check_rpn: Validate reverse Polish notation stack balance.
171
+ check_redundant: Detect redundant sub-expressions (e.g. ``a - a``).
172
+
173
+ Returns:
174
+ A dict with keys ``valid`` (bool), ``reasons`` (list[str]), and
175
+ ``expr`` (str representation of the parsed node, or None on error).
176
+ """
177
+ result = {"valid": True, "reasons": [], "expr": None}
178
+ try:
179
+ node = _parse(expr)
180
+ result["expr"] = str(node)
181
+ if max_depth is not None and _depth(node) > max_depth:
182
+ result["reasons"].append(f"max_depth:{_depth(node)}")
183
+ if max_nodes is not None and _node_count(node) > max_nodes:
184
+ result["reasons"].append(f"max_nodes:{_node_count(node)}")
185
+ if check_redundant:
186
+ self._check_redundant(node, result["reasons"])
187
+ if check_rpn:
188
+ self._check_rpn(_to_rpn(node), result["reasons"])
189
+ except Exception as e:
190
+ result["reasons"].append(str(e))
191
+ result["valid"] = len(result["reasons"]) == 0
192
+ return result
193
+
194
+ def _check_redundant(self, node: Call, reasons: list[str]):
195
+ if not isinstance(node, Call):
196
+ return
197
+ if node.fn_name in ("sub", "div") and len(node.args) == 2:
198
+ left, right = node.args
199
+ if str(left) == str(right):
200
+ reasons.append(f"redundant:{node.fn_name}")
201
+ for arg in node.args:
202
+ if isinstance(arg, Call):
203
+ self._check_redundant(arg, reasons)
204
+
205
+ def _check_rpn(self, rpn, reasons: list[str]):
206
+ from codestr.tokens import TokenType
207
+
208
+ stack = 0
209
+ for token in rpn:
210
+ if token.type in (
211
+ TokenType.FEATURE,
212
+ TokenType.CONSTANT,
213
+ TokenType.WINDOW,
214
+ TokenType.BINS,
215
+ TokenType.PARAM,
216
+ ):
217
+ stack += 1
218
+ elif token.type == TokenType.OPERATOR:
219
+ args_num = token.arity
220
+ if stack < args_num:
221
+ reasons.append(f"rpn_args:{token.value}")
222
+ return
223
+ stack = stack - args_num + 1
224
+ if stack != 1:
225
+ reasons.append("rpn_invalid")
226
+
227
+ def compile(self, expr: str) -> pl.Expr:
228
+ """Purely compile an expression string to a Polars Expression.
229
+
230
+ No side effects. The returned expression is bound to the column names
231
+ and over-window config of this CodeStr instance.
232
+ """
233
+ try:
234
+ node = _parse(expr)
235
+ return _pure_compile(
236
+ node,
237
+ registry=UDFRegistry.get_instance(),
238
+ dims=getattr(self, "dims", None),
239
+ ts_over=self._ts_over,
240
+ cs_over=self._cs_over,
241
+ )
242
+ except Exception as e:
243
+ raise CompileError(f"Pure compilation failed for {expr}: {e}") from e
244
+
245
+ def _compile_expr(self, expr: str, cover: bool):
246
+ """str表达式 -> polars 表达式"""
247
+ if self._data_ is None:
248
+ self._data_ = self.data.lazy()
249
+
250
+ try:
251
+ node = _parse(expr)
252
+ alias = node.alias
253
+ current_cols = set(self.cache_columns)
254
+
255
+ if alias in current_cols and not cover:
256
+ return pl.col(alias), alias
257
+ if node in self._expr_cache and not cover:
258
+ expr_pl = pl.col(self._expr_cache[node]).alias(alias)
259
+ self._data_ = self._data_.with_columns(expr_pl)
260
+ return pl.col(alias), alias
261
+ if node in self._cur_expr_cache and not cover:
262
+ expr_pl = pl.col(self._cur_expr_cache[node]).alias(alias)
263
+ self._data_ = self._data_.with_columns(expr_pl)
264
+ return pl.col(alias), alias
265
+
266
+ expr_pl = _pure_compile(
267
+ node,
268
+ registry=UDFRegistry.get_instance(),
269
+ dims=getattr(self, "dims", None),
270
+ ts_over=self._ts_over,
271
+ cs_over=self._cs_over,
272
+ )
273
+ self._data_ = self._data_.with_columns(expr_pl.alias(alias))
274
+ self._cur_expr_cache[node] = alias
275
+ return pl.col(alias), alias
276
+
277
+ except Exception as e:
278
+ raise CompileError(message=f"[表达式]: {expr}\n[编译器外层]\n{e}") from e
279
+
280
+ def sql(
281
+ self,
282
+ *exprs: str,
283
+ cover: bool = False,
284
+ lazy: bool = False,
285
+ ) -> pl.LazyFrame | pl.DataFrame:
286
+ """Execute one or more DSL expressions interactively.
287
+
288
+ This is the **stateful** API — results are cached in the engine and
289
+ may be reused across subsequent ``sql()`` calls.
290
+
291
+ Args:
292
+ exprs: DSL expression strings, e.g. ``"ts_mean(close, 5) as ma5"``.
293
+ cover: If True, recompute even if the alias already exists.
294
+ If False (default), skip computation on cache hits.
295
+ lazy: If True, return a ``pl.LazyFrame`` without materializing.
296
+ If False (default), collect and return a ``pl.DataFrame``.
297
+
298
+ Returns:
299
+ A DataFrame or LazyFrame containing the index columns and all
300
+ requested expression aliases.
301
+ """
302
+ self.failed = list()
303
+ exprs_to_add = list()
304
+ exprs_select = list()
305
+ self._cur_expr_cache = {}
306
+
307
+ # Snapshot _data_ so we can roll back on lazy-return or failure
308
+ _data_saved = self._data_
309
+
310
+ if self._last_query_cache is not None:
311
+ if self.data is None:
312
+ self.data = self._last_query_cache
313
+ else:
314
+ self.data = self.data.with_columns(
315
+ self._last_query_cache.select(
316
+ *[i for i in self._last_query_cache.columns if i not in self.data.columns]
317
+ )
318
+ )
319
+
320
+ for expr in exprs:
321
+ try:
322
+ compiled, alias = self._compile_expr(expr, cover)
323
+ if compiled is not None:
324
+ exprs_to_add.append(compiled)
325
+ exprs_select.append(alias)
326
+ except Exception as e:
327
+ self.failed.append(FailError(expr, e))
328
+ if self.failed:
329
+ logger.warning(f"CodeStr.sql 失败:{len(self.failed)}/{len(exprs)}: \n {self.failed}")
330
+ if self._data_ is None:
331
+ self._data_ = self.data.lazy()
332
+ self._data_ = self._data_.with_columns(*exprs_to_add)
333
+
334
+ if lazy:
335
+ self._expr_cache.update(self._cur_expr_cache)
336
+ result = self._data_.select(*self.index, *exprs_select)
337
+ self._data_ = _data_saved # roll back: don't accumulate in lazy mode
338
+ return result
339
+
340
+ current_cols = set(self._data_.collect_schema().names())
341
+ new_expr_cache = dict()
342
+ try:
343
+ self._last_query_cache = self._data_.select(*self.index, *exprs_select).collect()
344
+ self._expr_cache.update(self._cur_expr_cache)
345
+ for k, v in self._expr_cache.items():
346
+ if v in current_cols:
347
+ new_expr_cache[k] = v
348
+ self._expr_cache = new_expr_cache
349
+
350
+ return self._last_query_cache
351
+ except Exception as e:
352
+ for k, v in self._expr_cache.items():
353
+ if v in current_cols:
354
+ new_expr_cache[k] = v
355
+ self._expr_cache = new_expr_cache
356
+ self._data_ = _data_saved # roll back failed with_columns
357
+ raise PolarsError(message=f"LazyFrame.collect() 阶段出错\n{e}") from e
358
+
359
+ def clear_cache(self):
360
+ """清除缓存,重置计算图"""
361
+ self._data_ = None
362
+ self._expr_cache = {}
363
+ self._cur_expr_cache = {}
364
+ self._last_query_cache = None
codestr/errors.py ADDED
@@ -0,0 +1,71 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class ParseError(Exception):
6
+ """解析错误"""
7
+
8
+ message: str
9
+
10
+ def __str__(self):
11
+ return self.message
12
+
13
+ def __repr__(self):
14
+ return self.__str__()
15
+
16
+
17
+ @dataclass
18
+ class CalculateError(Exception):
19
+ """计算错误"""
20
+
21
+ message: str
22
+
23
+ def __str__(self):
24
+ return self.message
25
+
26
+ def __repr__(self):
27
+ return self.__str__()
28
+
29
+
30
+ @dataclass
31
+ class CompileError(Exception):
32
+ """编译错误"""
33
+
34
+ message: str
35
+
36
+ def __str__(self):
37
+ return self.message
38
+
39
+ def __repr__(self):
40
+ return self.__str__()
41
+
42
+
43
+ @dataclass
44
+ class PolarsError(Exception):
45
+ """Polars 引擎错误"""
46
+
47
+ message: str
48
+
49
+ def __str__(self):
50
+ return self.message
51
+
52
+ def __repr__(self):
53
+ return self.__str__()
54
+
55
+
56
+ @dataclass
57
+ class FailError:
58
+ """失败错误信息容器"""
59
+
60
+ expr: str
61
+ error: Exception
62
+
63
+ def __str__(self):
64
+ return f"""
65
+ [失败表达式]: {self.expr}
66
+ [错误类型]: {self.error.__class__.__name__}
67
+ [错误信息]: \n{self.error}
68
+ """
69
+
70
+ def __repr__(self):
71
+ return self.__str__()