peppermint-lang 0.1.0__tar.gz

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.
Files changed (27) hide show
  1. peppermint_lang-0.1.0/PKG-INFO +74 -0
  2. peppermint_lang-0.1.0/README.md +45 -0
  3. peppermint_lang-0.1.0/peppermint/__init__.py +0 -0
  4. peppermint_lang-0.1.0/peppermint/__main__.py +162 -0
  5. peppermint_lang-0.1.0/peppermint/ast_nodes.py +207 -0
  6. peppermint_lang-0.1.0/peppermint/bridge.py +220 -0
  7. peppermint_lang-0.1.0/peppermint/diagnostics.py +70 -0
  8. peppermint_lang-0.1.0/peppermint/interpreter.py +532 -0
  9. peppermint_lang-0.1.0/peppermint/libs/__init__.py +0 -0
  10. peppermint_lang-0.1.0/peppermint/libs/math_.py +41 -0
  11. peppermint_lang-0.1.0/peppermint/libs/ml.py +150 -0
  12. peppermint_lang-0.1.0/peppermint/libs/str_.py +72 -0
  13. peppermint_lang-0.1.0/peppermint/libs/viz.py +129 -0
  14. peppermint_lang-0.1.0/peppermint/parser.py +635 -0
  15. peppermint_lang-0.1.0/peppermint/stdlib/__init__.py +28 -0
  16. peppermint_lang-0.1.0/peppermint/stdlib/core.py +288 -0
  17. peppermint_lang-0.1.0/peppermint_lang.egg-info/PKG-INFO +74 -0
  18. peppermint_lang-0.1.0/peppermint_lang.egg-info/SOURCES.txt +25 -0
  19. peppermint_lang-0.1.0/peppermint_lang.egg-info/dependency_links.txt +1 -0
  20. peppermint_lang-0.1.0/peppermint_lang.egg-info/entry_points.txt +2 -0
  21. peppermint_lang-0.1.0/peppermint_lang.egg-info/requires.txt +20 -0
  22. peppermint_lang-0.1.0/peppermint_lang.egg-info/top_level.txt +1 -0
  23. peppermint_lang-0.1.0/pyproject.toml +34 -0
  24. peppermint_lang-0.1.0/setup.cfg +4 -0
  25. peppermint_lang-0.1.0/tests/test_grammar.py +332 -0
  26. peppermint_lang-0.1.0/tests/test_interpreter.py +232 -0
  27. peppermint_lang-0.1.0/tests/test_parser.py +431 -0
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: peppermint-lang
3
+ Version: 0.1.0
4
+ Summary: A pipe-first DSL for data and ML work
5
+ Author-email: Chayapatr Archiwaranguprok <pub@mit.edu>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/chayapatr/peppermint
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ Provides-Extra: data
14
+ Requires-Dist: pandas; extra == "data"
15
+ Provides-Extra: ml
16
+ Requires-Dist: pandas; extra == "ml"
17
+ Requires-Dist: scikit-learn; extra == "ml"
18
+ Requires-Dist: umap-learn; extra == "ml"
19
+ Provides-Extra: viz
20
+ Requires-Dist: pandas; extra == "viz"
21
+ Requires-Dist: matplotlib; extra == "viz"
22
+ Requires-Dist: seaborn; extra == "viz"
23
+ Provides-Extra: all
24
+ Requires-Dist: pandas; extra == "all"
25
+ Requires-Dist: scikit-learn; extra == "all"
26
+ Requires-Dist: umap-learn; extra == "all"
27
+ Requires-Dist: matplotlib; extra == "all"
28
+ Requires-Dist: seaborn; extra == "all"
29
+
30
+ # Peppermint
31
+
32
+ A pipe-first DSL for data and ML work. Designed to be lightweight and readable, where every operation is a pipeline step, errors propagate automatically, and the heavy lifting happens internally so you don't have to worry about it.
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ pip install -e .
38
+ ```
39
+
40
+ ## Run
41
+
42
+ ```sh
43
+ pep file.pep # run a file
44
+ pep # interactive REPL
45
+ ```
46
+
47
+ ## Example
48
+
49
+ ```
50
+ load("survey.csv")
51
+ |> filter(it.age > 18)
52
+ |> add(score: it.income / it.age)
53
+ |> sort(by: "score", dir: "desc")
54
+ |> print()
55
+ ```
56
+
57
+ ```
58
+ load("survey.csv")
59
+ |> group(by: "region") {
60
+ |> agg(avg_score: mean(it.score), n: count())
61
+ }
62
+ |> sort(by: "avg_score", dir: "desc")
63
+ |> print()
64
+ ```
65
+
66
+ Each step prints a summary as it runs:
67
+
68
+ ```
69
+ |> filter → List 843 rows × 5 cols (157 dropped)
70
+ |> add → List 843 rows × 6 cols (+score)
71
+ |> sort → List 843 rows × 6 cols
72
+ ```
73
+
74
+ See [docs/language.md](docs/language.md) for the full language reference and [examples/](examples/) for more.
@@ -0,0 +1,45 @@
1
+ # Peppermint
2
+
3
+ A pipe-first DSL for data and ML work. Designed to be lightweight and readable, where every operation is a pipeline step, errors propagate automatically, and the heavy lifting happens internally so you don't have to worry about it.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install -e .
9
+ ```
10
+
11
+ ## Run
12
+
13
+ ```sh
14
+ pep file.pep # run a file
15
+ pep # interactive REPL
16
+ ```
17
+
18
+ ## Example
19
+
20
+ ```
21
+ load("survey.csv")
22
+ |> filter(it.age > 18)
23
+ |> add(score: it.income / it.age)
24
+ |> sort(by: "score", dir: "desc")
25
+ |> print()
26
+ ```
27
+
28
+ ```
29
+ load("survey.csv")
30
+ |> group(by: "region") {
31
+ |> agg(avg_score: mean(it.score), n: count())
32
+ }
33
+ |> sort(by: "avg_score", dir: "desc")
34
+ |> print()
35
+ ```
36
+
37
+ Each step prints a summary as it runs:
38
+
39
+ ```
40
+ |> filter → List 843 rows × 5 cols (157 dropped)
41
+ |> add → List 843 rows × 6 cols (+score)
42
+ |> sort → List 843 rows × 6 cols
43
+ ```
44
+
45
+ See [docs/language.md](docs/language.md) for the full language reference and [examples/](examples/) for more.
File without changes
@@ -0,0 +1,162 @@
1
+ import sys
2
+ import argparse
3
+ sys.setrecursionlimit(50000)
4
+ from .parser import parse, ParseError
5
+ from .interpreter import Interpreter, Err, Ok, PepError
6
+ from .stdlib import build_global_env
7
+ from .diagnostics import report_parse_error, report_pep_error, report_err
8
+ from .interpreter import ListValue, PmFunction, PmRange
9
+
10
+
11
+ def _repl_display(value):
12
+ if value is None:
13
+ return
14
+ if isinstance(value, bool):
15
+ val_str = "true" if value else "false"
16
+ type_str = "bool"
17
+ elif isinstance(value, int):
18
+ val_str = str(value)
19
+ type_str = "num"
20
+ elif isinstance(value, float):
21
+ val_str = str(value)
22
+ type_str = "num"
23
+ elif isinstance(value, str):
24
+ val_str = repr(value)
25
+ type_str = "str"
26
+ elif isinstance(value, ListValue):
27
+ val_str = repr(value)
28
+ type_str = ""
29
+ elif isinstance(value, PmFunction):
30
+ val_str = repr(value)
31
+ type_str = "fn"
32
+ elif isinstance(value, PmRange):
33
+ val_str = f"{value.start}..{value.end}"
34
+ type_str = "range"
35
+ elif isinstance(value, dict):
36
+ pairs = ", ".join(f"{k}: {v!r}" for k, v in value.items())
37
+ val_str = "{ " + pairs + " }"
38
+ type_str = "obj"
39
+ elif isinstance(value, list):
40
+ val_str = repr(value)
41
+ type_str = "list"
42
+ else:
43
+ val_str = str(value)
44
+ type_str = type(value).__name__
45
+
46
+ prefix = "\033[32m<<<\033[0m"
47
+ # "<<< " is 4 chars; pad so arrow aligns at col 34 (matching pipe step)
48
+ if type_str:
49
+ pad = max(0, 34 - 4 - len(val_str))
50
+ tag = f"\033[33m← {type_str}\033[0m"
51
+ print(f"{prefix} {val_str}{' ' * pad}\033[33m←\033[0m \033[33m{type_str}\033[0m")
52
+ else:
53
+ print(f"{prefix} {val_str}")
54
+
55
+
56
+ def run_file(args):
57
+ try:
58
+ src = open(args.file).read()
59
+ except FileNotFoundError:
60
+ print(f"\033[1;31mError:\033[0m × file not found: {args.file}", file=sys.stderr)
61
+ sys.exit(1)
62
+
63
+ try:
64
+ program = parse(src)
65
+ except ParseError as e:
66
+ report_parse_error(e, src, args.file)
67
+ sys.exit(1)
68
+ except Exception as e:
69
+ print(f"\033[1;31mError:\033[0m × {e}", file=sys.stderr)
70
+ sys.exit(1)
71
+
72
+ env = build_global_env()
73
+ interp = Interpreter(env, quiet=args.quiet)
74
+
75
+ try:
76
+ result = interp.run(program)
77
+ except PepError as e:
78
+ report_pep_error(e, src, args.file)
79
+ sys.exit(1)
80
+ except Exception as e:
81
+ print(f"\033[1;31mError:\033[0m × {e}", file=sys.stderr)
82
+ sys.exit(1)
83
+
84
+ if isinstance(result, Err):
85
+ report_err(result, src, args.file)
86
+ sys.exit(1)
87
+
88
+
89
+ def run_repl(args):
90
+ import readline # enables arrow keys and history
91
+ env = build_global_env()
92
+ interp = Interpreter(env, quiet=False)
93
+
94
+ print("Peppermint REPL (Ctrl+D to exit)")
95
+
96
+ buf = []
97
+
98
+ while True:
99
+ prompt = "\033[2m...\033[0m " if buf else "\033[32m>>>\033[0m "
100
+ try:
101
+ line = input(prompt)
102
+ except EOFError:
103
+ print()
104
+ break
105
+ except KeyboardInterrupt:
106
+ print()
107
+ buf = []
108
+ continue
109
+
110
+ stripped = line.rstrip()
111
+ buf.append(line)
112
+
113
+ # Try joining with newlines first, then with spaces (handles `1 +\n2` style)
114
+ program = None
115
+ for src in ("\n".join(buf), " ".join(buf)):
116
+ try:
117
+ program = parse(src)
118
+ break
119
+ except Exception:
120
+ pass
121
+
122
+ if program is None:
123
+ if stripped == "" and len(buf) == 1:
124
+ buf = []
125
+ continue
126
+
127
+ buf = []
128
+
129
+ try:
130
+ result = interp.run(program)
131
+ except PepError as e:
132
+ report_pep_error(e, src, "<repl>") # src is still in scope from the loop above
133
+ continue
134
+ except Exception as e:
135
+ print(f"\033[1;31mError:\033[0m × {e}", file=sys.stderr)
136
+ continue
137
+
138
+ if result is None:
139
+ continue
140
+ if isinstance(result, Err):
141
+ report_err(result, src, "<repl>")
142
+ elif isinstance(result, Ok):
143
+ _repl_display(result.value)
144
+ else:
145
+ _repl_display(result)
146
+
147
+
148
+ def main():
149
+ ap = argparse.ArgumentParser(prog="pep", description="Peppermint language")
150
+ ap.add_argument("file", nargs="?", help="Path to .pep file (omit to start REPL)")
151
+ ap.add_argument("--quiet", action="store_true", help="Suppress pipe step summaries")
152
+
153
+ args = ap.parse_args()
154
+
155
+ if args.file:
156
+ run_file(args)
157
+ else:
158
+ run_repl(args)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Any
4
+
5
+
6
+ @dataclass
7
+ class Loc:
8
+ line: int
9
+ col: int
10
+
11
+ def __repr__(self):
12
+ return f"{self.line}:{self.col}"
13
+
14
+
15
+ NO_LOC = Loc(0, 0)
16
+
17
+ # --- Patterns (used in match arms) ---
18
+
19
+ @dataclass
20
+ class PatComparison:
21
+ op: str # >, <, >=, <=, ==, !=
22
+ value: Any # int | float | str | Expr (variable)
23
+ loc: Loc = field(default_factory=lambda: NO_LOC)
24
+
25
+ @dataclass
26
+ class PatOk:
27
+ name: str
28
+ loc: Loc = field(default_factory=lambda: NO_LOC)
29
+
30
+ @dataclass
31
+ class PatErr:
32
+ name: str
33
+ loc: Loc = field(default_factory=lambda: NO_LOC)
34
+
35
+ @dataclass
36
+ class PatTuple:
37
+ patterns: list
38
+ loc: Loc = field(default_factory=lambda: NO_LOC)
39
+
40
+ @dataclass
41
+ class PatWildcard:
42
+ loc: Loc = field(default_factory=lambda: NO_LOC)
43
+
44
+ Pattern = PatComparison | PatOk | PatErr | PatTuple | PatWildcard
45
+
46
+ # --- Expressions ---
47
+
48
+ @dataclass
49
+ class IntLit:
50
+ value: int
51
+ loc: Loc = field(default_factory=lambda: NO_LOC)
52
+
53
+ @dataclass
54
+ class FloatLit:
55
+ value: float
56
+ loc: Loc = field(default_factory=lambda: NO_LOC)
57
+
58
+ @dataclass
59
+ class StrLit:
60
+ value: str
61
+ loc: Loc = field(default_factory=lambda: NO_LOC)
62
+
63
+ @dataclass
64
+ class BoolLit:
65
+ value: bool
66
+ loc: Loc = field(default_factory=lambda: NO_LOC)
67
+
68
+ @dataclass
69
+ class NoneLit:
70
+ loc: Loc = field(default_factory=lambda: NO_LOC)
71
+
72
+ @dataclass
73
+ class Ident:
74
+ name: str
75
+ loc: Loc = field(default_factory=lambda: NO_LOC)
76
+
77
+ @dataclass
78
+ class FieldAccess:
79
+ obj: Any # Expr
80
+ field: str
81
+ loc: Loc = field(default_factory=lambda: NO_LOC)
82
+
83
+ @dataclass
84
+ class Neg:
85
+ operand: Any # Expr
86
+ loc: Loc = field(default_factory=lambda: NO_LOC)
87
+
88
+ @dataclass
89
+ class BinOp:
90
+ op: str # +, -, *, /, >, <, >=, <=, ==, !=
91
+ left: Any # Expr
92
+ right: Any # Expr
93
+ loc: Loc = field(default_factory=lambda: NO_LOC)
94
+
95
+ @dataclass
96
+ class Call:
97
+ func: Any # Expr — already resolved to FieldAccess or Ident by postfix rule
98
+ args: list # list[Expr]
99
+ kwargs: dict # str -> Expr
100
+ block: list | None # list[PipeStep] | None
101
+ loc: Loc = field(default_factory=lambda: NO_LOC)
102
+
103
+ @dataclass
104
+ class Lambda:
105
+ params: list[str]
106
+ body: Any # Expr
107
+ loc: Loc = field(default_factory=lambda: NO_LOC)
108
+
109
+ @dataclass
110
+ class Pipe:
111
+ steps: list # list[Expr] — first is the source, rest are calls
112
+ loc: Loc = field(default_factory=lambda: NO_LOC)
113
+
114
+ @dataclass
115
+ class PipeStep:
116
+ expr: Any # Call
117
+ quiet: bool
118
+ loc: Loc = field(default_factory=lambda: NO_LOC)
119
+
120
+ @dataclass
121
+ class Match:
122
+ subject: Any # Expr
123
+ arms: list # list[MatchArm]
124
+ loc: Loc = field(default_factory=lambda: NO_LOC)
125
+
126
+ @dataclass
127
+ class MatchArm:
128
+ pattern: Pattern
129
+ body: Any # Expr
130
+ loc: Loc = field(default_factory=lambda: NO_LOC)
131
+
132
+ @dataclass
133
+ class ListLit:
134
+ items: list # list[Expr]
135
+ loc: Loc = field(default_factory=lambda: NO_LOC)
136
+
137
+ @dataclass
138
+ class ObjField:
139
+ key: str
140
+ value: Any # Expr
141
+ loc: Loc = field(default_factory=lambda: NO_LOC)
142
+
143
+ @dataclass
144
+ class ObjSpread:
145
+ obj: Any # Expr
146
+ loc: Loc = field(default_factory=lambda: NO_LOC)
147
+
148
+ @dataclass
149
+ class ObjShorthand:
150
+ key: str # { x } = { x: x }
151
+ loc: Loc = field(default_factory=lambda: NO_LOC)
152
+
153
+ @dataclass
154
+ class ObjLit:
155
+ entries: list # list[ObjField | ObjSpread]
156
+ loc: Loc = field(default_factory=lambda: NO_LOC)
157
+
158
+ @dataclass
159
+ class TupleLit:
160
+ items: list # list[Expr]
161
+ loc: Loc = field(default_factory=lambda: NO_LOC)
162
+
163
+ @dataclass
164
+ class Spread:
165
+ obj: Any # Expr
166
+ loc: Loc = field(default_factory=lambda: NO_LOC)
167
+
168
+ @dataclass
169
+ class Range:
170
+ start: int
171
+ end: int
172
+ loc: Loc = field(default_factory=lambda: NO_LOC)
173
+
174
+ @dataclass
175
+ class Block:
176
+ stmts: list # list[Expr] — evaluates each, returns last
177
+ loc: Loc = field(default_factory=lambda: NO_LOC)
178
+
179
+ @dataclass
180
+ class Literal:
181
+ value: Any # already-evaluated runtime value, passes through eval unchanged
182
+ loc: Loc = field(default_factory=lambda: NO_LOC)
183
+
184
+ # --- Statements ---
185
+
186
+ @dataclass
187
+ class Assign:
188
+ name: str
189
+ value: Any # Expr
190
+ loc: Loc = field(default_factory=lambda: NO_LOC)
191
+
192
+ @dataclass
193
+ class UseDecl:
194
+ path: str # module name or file path string
195
+ alias: str | None
196
+ loc: Loc = field(default_factory=lambda: NO_LOC)
197
+
198
+ @dataclass
199
+ class NsDecl:
200
+ name: str
201
+ body: list # list[Assign]
202
+ loc: Loc = field(default_factory=lambda: NO_LOC)
203
+
204
+ @dataclass
205
+ class Program:
206
+ body: list # list[Assign | UseDecl | NsDecl | Expr]
207
+ loc: Loc = field(default_factory=lambda: NO_LOC)
@@ -0,0 +1,220 @@
1
+ """
2
+ Peppermint ↔ Python bridge.
3
+
4
+ Python libraries loaded via `use` should import from here rather than
5
+ touching interpreter internals directly. The bridge is the single place
6
+ that knows about both worlds.
7
+ """
8
+ from __future__ import annotations
9
+ from typing import Any, Callable
10
+ import functools
11
+
12
+ # Single cached tuple — atomic assignment avoids the partial-init race
13
+ _types: tuple | None = None
14
+
15
+ def _interp_types():
16
+ global _types
17
+ if _types is None:
18
+ from .interpreter import Ok, Err, ListValue
19
+ _types = (Ok, Err, ListValue)
20
+ return _types
21
+
22
+
23
+ # --- Type predicates ---
24
+
25
+ def is_ok(val) -> bool:
26
+ Ok, Err, ListValue = _interp_types()
27
+ return isinstance(val, Ok)
28
+
29
+ def is_err(val) -> bool:
30
+ Ok, Err, ListValue = _interp_types()
31
+ return isinstance(val, Err)
32
+
33
+ def is_list(val) -> bool:
34
+ Ok, Err, ListValue = _interp_types()
35
+ return isinstance(val, (ListValue, list))
36
+
37
+ def is_object_list(val) -> bool:
38
+ Ok, Err, ListValue = _interp_types()
39
+ if isinstance(val, ListValue):
40
+ return True
41
+ if isinstance(val, list):
42
+ return all(isinstance(el, dict) for el in val)
43
+ return False
44
+
45
+
46
+ # --- Conversion: Peppermint → Python ---
47
+
48
+ def to_python(val) -> Any:
49
+ """Unwrap Peppermint runtime values to plain Python.
50
+
51
+ Never raises — Err is returned as its message string so callers
52
+ don't need to handle two failure modes.
53
+ """
54
+ Ok, Err, ListValue = _interp_types()
55
+ if isinstance(val, Ok):
56
+ return to_python(val.value)
57
+ if isinstance(val, Err):
58
+ return val.msg # caller decides what to do with it
59
+ if isinstance(val, ListValue):
60
+ return [to_python(r) for r in val.rows]
61
+ if isinstance(val, list):
62
+ return [to_python(r) for r in val]
63
+ try:
64
+ import numpy as np
65
+ if isinstance(val, np.integer): return int(val)
66
+ if isinstance(val, np.floating): return float(val)
67
+ if isinstance(val, np.ndarray): return val.tolist()
68
+ except ImportError:
69
+ pass
70
+ try:
71
+ import pandas as pd
72
+ if isinstance(val, pd.DataFrame): return val.to_dict(orient="records")
73
+ if isinstance(val, pd.Series): return val.tolist()
74
+ except ImportError:
75
+ pass
76
+ return val
77
+
78
+
79
+ # --- Conversion: Python → Peppermint ---
80
+
81
+ def _infer_schema(rows: list[dict]) -> dict:
82
+ schema: dict = {}
83
+ unknown: set = set()
84
+ for row in rows:
85
+ for k, v in row.items():
86
+ if k in schema:
87
+ continue
88
+ if v is not None:
89
+ schema[k] = type(v)
90
+ else:
91
+ unknown.add(k)
92
+ # columns that were None in every row get type NoneType
93
+ for k in unknown - schema.keys():
94
+ schema[k] = type(None)
95
+ return schema
96
+
97
+
98
+ def make_list(rows: list[dict]):
99
+ """Wrap a list of dicts into a Peppermint ListValue."""
100
+ Ok, Err, ListValue = _interp_types()
101
+ return ListValue(rows=rows, schema=_infer_schema(rows))
102
+
103
+
104
+ def _normalize(val) -> Any:
105
+ """Coerce numpy/pandas types and unwrap Ok; never recurses into ListValue."""
106
+ Ok, Err, ListValue = _interp_types()
107
+ if isinstance(val, Ok):
108
+ return _normalize(val.value)
109
+ if isinstance(val, ListValue):
110
+ return val # preserve as-is; from_python will wrap in Ok
111
+ try:
112
+ import numpy as np
113
+ if isinstance(val, np.integer): return int(val)
114
+ if isinstance(val, np.floating): return float(val)
115
+ if isinstance(val, np.ndarray): return val.tolist()
116
+ except ImportError:
117
+ pass
118
+ try:
119
+ import pandas as pd
120
+ if isinstance(val, pd.DataFrame): return val.to_dict(orient="records")
121
+ if isinstance(val, pd.Series): return val.tolist()
122
+ except ImportError:
123
+ pass
124
+ return val
125
+
126
+
127
+ def from_python(val) -> Any:
128
+ """Wrap a plain Python value into a Peppermint Ok result."""
129
+ Ok, Err, ListValue = _interp_types()
130
+ val = _normalize(val)
131
+ if isinstance(val, ListValue):
132
+ return Ok(val) # already tabular, schema intact
133
+ if isinstance(val, list):
134
+ if all(isinstance(el, dict) for el in val):
135
+ return Ok(make_list(val))
136
+ return Ok(val) # scalar list — not tabular data
137
+ return Ok(val)
138
+
139
+
140
+ def err(msg: str):
141
+ """Return a Peppermint Err."""
142
+ Ok, Err, ListValue = _interp_types()
143
+ return Err(msg)
144
+
145
+
146
+ def ok(val):
147
+ """Return a Peppermint Ok."""
148
+ Ok, Err, ListValue = _interp_types()
149
+ return Ok(val)
150
+
151
+
152
+ # --- Row utilities ---
153
+
154
+ def get_rows(val) -> list[dict]:
155
+ """Extract rows from a ListValue or plain list[dict]."""
156
+ Ok, Err, ListValue = _interp_types()
157
+ if isinstance(val, ListValue):
158
+ return val.rows
159
+ if isinstance(val, list):
160
+ return val
161
+ raise TypeError(f"expected a list, got {type(val).__name__}")
162
+
163
+
164
+ def map_rows(val, fn: Callable[[dict], dict]):
165
+ """Apply fn to each row, return a new ListValue wrapped in Ok."""
166
+ rows = get_rows(val)
167
+ return from_python([fn(row) for row in rows])
168
+
169
+
170
+ def add_column(val, name: str, fn: Callable[[dict], Any]):
171
+ """Add a new field to every row using fn(row) -> value."""
172
+ rows = get_rows(val)
173
+ return from_python([{**row, name: fn(row)} for row in rows])
174
+
175
+
176
+ def filter_rows(val, fn: Callable[[dict], bool]):
177
+ """Keep rows where fn(row) is truthy."""
178
+ rows = get_rows(val)
179
+ return from_python([row for row in rows if fn(row)])
180
+
181
+
182
+ # --- Library loader ---
183
+
184
+ def load_python_file(path: str, alias: str | None = None) -> dict:
185
+ """Import a .py file and return its public functions wrapped for Peppermint."""
186
+ import importlib.util, inspect
187
+
188
+ spec = importlib.util.spec_from_file_location("_pep_user_lib", path)
189
+ mod = importlib.util.module_from_spec(spec)
190
+ spec.loader.exec_module(mod)
191
+
192
+ return {
193
+ name: wrap(obj)
194
+ for name, obj in inspect.getmembers(mod, inspect.isfunction)
195
+ if not name.startswith("_")
196
+ }
197
+
198
+
199
+ def wrap(fn: Callable) -> Callable:
200
+ """Wrap a plain Python function for use in Peppermint.
201
+
202
+ - Args converted from Peppermint values to plain Python
203
+ - Return value wrapped in Ok
204
+ - Exceptions become Err
205
+ """
206
+ @functools.wraps(fn)
207
+ def wrapper(*args, _interp=None, _env=None, _block=None, **kwargs):
208
+ try:
209
+ converted = [to_python(a) for a in args]
210
+ converted_kwargs = {k: to_python(v) for k, v in kwargs.items()}
211
+ return from_python(fn(*converted, **converted_kwargs))
212
+ except Exception as e:
213
+ Ok, Err, ListValue = _interp_types()
214
+ return Err(str(e))
215
+ return wrapper
216
+
217
+
218
+ def wrap_lib(fns: dict) -> dict:
219
+ """Wrap a dict of plain Python functions for use in Peppermint."""
220
+ return {name: wrap(fn) for name, fn in fns.items()}