duqlang 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.
duqlang-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.3
2
+ Name: duqlang
3
+ Version: 0.1.0
4
+ Summary: Data-processing language and CLI
5
+ Requires-Dist: bs4>=0.0.2
6
+ Requires-Dist: httpx>=0.28.1
7
+ Requires-Dist: prompt-toolkit>=3.0.53
8
+ Requires-Python: >=3.12
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "duqlang"
3
+ version = "0.1.0"
4
+ description = "Data-processing language and CLI"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "bs4>=0.0.2",
8
+ "httpx>=0.28.1",
9
+ "prompt-toolkit>=3.0.53",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "ipykernel>=7.3.0",
15
+ "ipywidgets>=8.1.9",
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.8.19,<0.9.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [tool.uv.build-backend]
23
+ module-name = "duq"
@@ -0,0 +1,3 @@
1
+ from duq._evaluation import evaluate
2
+ from duq._tui import run_tui
3
+ from duq._preview import print_value, print_value_async
@@ -0,0 +1,30 @@
1
+ import asyncio
2
+ import sys
3
+ import warnings
4
+
5
+ import duq
6
+
7
+
8
+ def catch_warning(message, category, filename, lineno, file=None, line=None):
9
+ pass
10
+
11
+
12
+ warnings.showwarning = catch_warning
13
+ # warnings.filterwarnings(
14
+ # "ignore", message=".*was never awaited.*", category=RuntimeWarning
15
+ # )
16
+ # warnings.filterwarnings(
17
+ # "ignore", message=".*invalid escape sequence.*", category=SyntaxWarning
18
+ # )
19
+
20
+ if len(sys.argv) == 1:
21
+ asyncio.run(duq.run_tui())
22
+ sys.exit(0)
23
+
24
+ if len(sys.argv) != 2:
25
+ print("Usage: duq '<source>'", file=sys.stderr)
26
+ sys.exit(1)
27
+
28
+ source = sys.argv[1]
29
+ result = duq.evaluate(source)
30
+ asyncio.run(duq.print_value_async(result))
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, assert_never
5
+
6
+ from duq._syntax import ChainExpr, Expr, OpExpr, parse
7
+ from duq._type_check import DuqTypeError, OpSignature, type_check_value
8
+
9
+
10
+ @dataclass
11
+ class OpInfo:
12
+ name: str
13
+ signature: OpSignature
14
+ op_func: Callable
15
+
16
+
17
+ class DuqContext:
18
+ def __init__(self) -> None:
19
+ self.ops: dict[str, OpInfo] = {}
20
+ self.cache: dict[str, Any] = {}
21
+ self.cursor: int | None = None
22
+
23
+ @staticmethod
24
+ def create() -> DuqContext:
25
+ import duq._std
26
+
27
+ context = DuqContext()
28
+ context.load_ops(duq._std.op_definitions)
29
+ return context
30
+
31
+ def load_op(self, name: str, op_func: Callable) -> None:
32
+ if name in self.ops:
33
+ raise Exception(f"redefined op: `{name}`")
34
+ signature = OpSignature.of(name, op_func)
35
+ self.ops[name] = OpInfo(name, signature, op_func)
36
+
37
+ def load_ops(self, ops: dict[str, Callable]) -> None:
38
+ for op_name, op_func in ops.items():
39
+ self.load_op(op_name, op_func)
40
+
41
+ def resolve_op(self, name: str, input: Any) -> OpInfo:
42
+ overloads = [op for op in self.ops.values() if op.signature.matches_name(name)]
43
+ if len(overloads) == 1:
44
+ return overloads[0]
45
+ if len(overloads) == 0:
46
+ raise Exception(f"unknown operation `{name}`")
47
+
48
+ matches = [op for op in overloads if op.signature.matches_input(input)]
49
+ if len(matches) == 1:
50
+ return matches[0]
51
+ if len(matches) == 0:
52
+ example_op = overloads[0]
53
+ assert (input_param := example_op.signature.input_param) is not None
54
+ try:
55
+ type_check_value(input, input_param.annotation)
56
+ except DuqTypeError as e:
57
+ raise DuqTypeError(
58
+ f"{example_op.name} (+{len(overloads) - 1} overloads): {e.args[0]}"
59
+ )
60
+
61
+ op_names = ", ".join(f"`{op.name}`" for op in matches)
62
+ raise Exception(f"ambiguous operation `{name}`: {op_names}")
63
+
64
+ def evaluate_op_expr(self, expr: OpExpr, input: Any) -> Any:
65
+ args = []
66
+ if expr.name.text.startswith("."):
67
+ op = self.resolve_op("std.record.field", input)
68
+ args.append(expr.name.text.removeprefix("."))
69
+ elif expr.name.text == "{":
70
+ op = self.resolve_op("map", input)
71
+ else:
72
+ op = self.resolve_op(expr.name.text, input)
73
+
74
+ arg_exprs = () if expr.arg_list is None else expr.arg_list.args
75
+ if op.signature.is_macro:
76
+ args += arg_exprs
77
+ else:
78
+ args += [self.evaluate(arg_expr, input) for arg_expr in arg_exprs]
79
+
80
+ args = tuple(args)
81
+ call_args = op.signature.type_check_inputs(self, input, args)
82
+
83
+ return op.op_func(*call_args)
84
+
85
+ def evaluate_chain_expr(self, expr: ChainExpr, input: Any) -> Any:
86
+ contains_cursor = (
87
+ self.cursor is not None
88
+ and self.cursor >= expr.span[0]
89
+ and self.cursor <= expr.span[1]
90
+ )
91
+ for subexpr in expr.exprs:
92
+ if (
93
+ self.cursor is not None
94
+ and contains_cursor
95
+ and subexpr.span[0] >= self.cursor
96
+ ):
97
+ break
98
+ input = self.evaluate(subexpr, input)
99
+ return input
100
+
101
+ def evaluate(self, expr: Expr, input: Any) -> Any:
102
+ if expr.type == "literal":
103
+ return expr.value
104
+ elif expr.type == "op":
105
+ return self.evaluate_op_expr(expr, input)
106
+ elif expr.type == "chain":
107
+ return self.evaluate_chain_expr(expr, input)
108
+ else:
109
+ assert_never(expr)
110
+
111
+
112
+ def evaluate(source: str, input: Any = None) -> Any:
113
+ context = DuqContext.create()
114
+ expr = parse(source)
115
+ return context.evaluate(expr, input)
@@ -0,0 +1,165 @@
1
+ import asyncio
2
+ import html
3
+ import json
4
+ import re
5
+ from typing import Any, AsyncIterator, Awaitable, Callable, Iterable
6
+
7
+ import bs4
8
+
9
+ from duq._evaluation import DuqContext
10
+ from duq._syntax import parse
11
+ from duq._util import DuqFuture, DuqStream, Hinted, Reactive
12
+
13
+
14
+ def render_items(
15
+ start: str, end: str, items: Iterable[str], indent: int, sep=","
16
+ ) -> str:
17
+ start = html.escape(start)
18
+ end = html.escape(end)
19
+ sep = html.escape(sep)
20
+ indent_str = " " * indent
21
+ if not items:
22
+ return start + end
23
+ elif len(result := start + (sep + " ").join(items) + end) < 40:
24
+ return result
25
+ else:
26
+ result = start
27
+ for item in items:
28
+ result += html.escape("\n") + indent_str + " " + item + sep
29
+ result += html.escape("\n") + indent_str + end
30
+ return result
31
+
32
+
33
+ def render_value(value: Any, indent=0) -> Reactive[str]:
34
+ if value is None or isinstance(value, (int, float, str, bool)):
35
+ return Reactive.of(html.escape(json.dumps(value)))
36
+ elif isinstance(value, list):
37
+ child_strs_rx = Reactive.from_list(
38
+ [render_value(child, indent + 2) for child in value]
39
+ )
40
+ return child_strs_rx.map(
41
+ lambda child_strs: render_items("[", "]", child_strs, indent)
42
+ )
43
+ elif isinstance(value, dict):
44
+ child_strs_rx = Reactive.from_list(
45
+ [
46
+ render_value(child, indent + 2).map(
47
+ lambda s: f"{html.escape(json.dumps(key))}: {s}"
48
+ )
49
+ for key, child in value.items()
50
+ ]
51
+ )
52
+ return child_strs_rx.map(
53
+ lambda child_strs: render_items("{", "}", child_strs, indent)
54
+ )
55
+ elif isinstance(value, Awaitable):
56
+ return Reactive.from_awaitable(value).flat_map(
57
+ lambda child_maybe: (
58
+ render_value(child_maybe.value, indent + 2)
59
+ if child_maybe.is_some
60
+ else Reactive.of("...")
61
+ ).map(
62
+ lambda child_str: render_items(
63
+ "future(", ")", [child_str], indent, sep=""
64
+ )
65
+ )
66
+ )
67
+ elif isinstance(value, DuqFuture):
68
+ return render_value(value.create(), indent)
69
+ elif isinstance(value, AsyncIterator):
70
+
71
+ async def child_strs_iter() -> AsyncIterator[Reactive[str]]:
72
+ async for child in value:
73
+ yield render_value(child, indent + 2)
74
+
75
+ return Reactive.collect_from_iter(child_strs_iter()).map(
76
+ lambda child_strs: render_items(
77
+ "stream[",
78
+ "]",
79
+ child_strs.items + ([] if child_strs.is_done else ["..."]),
80
+ indent,
81
+ )
82
+ )
83
+ elif isinstance(value, DuqStream):
84
+ return render_value(value.create(), indent)
85
+ elif isinstance(value, Hinted):
86
+ child_rx = render_value(value.value, indent)
87
+ if value.hint == "cursor":
88
+
89
+ def bold(s: str) -> str:
90
+ lines = s.split("\n")
91
+ lines = [f"<green>{line}</green>" for line in lines]
92
+ return "\n".join(lines)
93
+
94
+ return child_rx.map(bold)
95
+ else:
96
+ return child_rx
97
+ elif isinstance(value, bs4.Tag):
98
+ indented = re.sub(
99
+ r"^(\s*)",
100
+ (indent * " ") + r"\1\1",
101
+ value.prettify(),
102
+ flags=re.MULTILINE,
103
+ ).removeprefix(indent * " ")
104
+ output = re.sub(r"\s*$", "", indented)
105
+ return Reactive.of(html.escape(output))
106
+ else:
107
+ raise Exception(f"unimplemented: {type(value)}")
108
+
109
+
110
+ class Preview:
111
+ def __init__(
112
+ self, set_output: Callable[[str], None], set_error: Callable[[str], None]
113
+ ) -> None:
114
+ self.ctx = DuqContext.create()
115
+ self.source = ""
116
+ self.cursor_position = 0
117
+ self.current_task: asyncio.Task | None = None
118
+ self.set_output = set_output
119
+ self.set_error = set_error
120
+ self.refresh()
121
+
122
+ def set_source(self, source: str) -> None:
123
+ self.source = source
124
+ self.refresh()
125
+
126
+ def set_cursor_position(self, cursor_position: int) -> None:
127
+ self.cursor_position = cursor_position
128
+ self.refresh()
129
+
130
+ def refresh(self) -> None:
131
+ try:
132
+ expr = parse(self.source, self.cursor_position)
133
+ self.ctx.cursor = self.cursor_position
134
+ result = self.ctx.evaluate(expr, None)
135
+ except Exception as e:
136
+ self.set_error(f"Error: {e}")
137
+ else:
138
+
139
+ async def set_output_task():
140
+ try:
141
+ output_rx = render_value(result)
142
+ self.set_output(output_rx.initial)
143
+ async for output in output_rx.updates:
144
+ self.set_output(output)
145
+ except Exception as e:
146
+ while isinstance(e, ExceptionGroup):
147
+ e = e.exceptions[0]
148
+ self.set_error(f"Error: {e}")
149
+
150
+ if self.current_task != None:
151
+ self.current_task.cancel()
152
+ self.current_task = asyncio.create_task(set_output_task())
153
+ self.set_error("")
154
+
155
+
156
+ async def print_value_async(value: Any) -> None:
157
+ value_str_rx = render_value(value)
158
+ print(html.unescape(value_str_rx.initial))
159
+ async for value_str in value_str_rx.updates:
160
+ print(html.unescape(value_str))
161
+
162
+
163
+ def print_value(value: Any) -> None:
164
+ value_str_rx = render_value(value)
165
+ print(html.unescape(value_str_rx.initial))
@@ -0,0 +1,37 @@
1
+ from duq._std import (
2
+ _basic,
3
+ _csv,
4
+ _file,
5
+ _future,
6
+ _html,
7
+ _http,
8
+ _json,
9
+ _bool,
10
+ _list,
11
+ _null,
12
+ _number,
13
+ _record,
14
+ _str,
15
+ _stream,
16
+ )
17
+
18
+ op_definitions = {
19
+ op_name: op_func
20
+ for op_definitions in [
21
+ _basic.op_definitions,
22
+ _bool.op_definitions,
23
+ _csv.op_definitions,
24
+ _file.op_definitions,
25
+ _future.op_definitions,
26
+ _html.op_definitions,
27
+ _http.op_definitions,
28
+ _json.op_definitions,
29
+ _list.op_definitions,
30
+ _null.op_definitions,
31
+ _number.op_definitions,
32
+ _record.op_definitions,
33
+ _str.op_definitions,
34
+ _stream.op_definitions,
35
+ ]
36
+ for op_name, op_func in op_definitions.items()
37
+ }
@@ -0,0 +1,38 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ from duq._evaluation import DuqContext
5
+ from duq._syntax import Expr
6
+ from duq._util import Hinted
7
+
8
+
9
+ def op_id(input: Any) -> Any:
10
+ return input
11
+
12
+
13
+ def op_eq(input: Any, arg: Any) -> bool:
14
+ return input == arg
15
+
16
+
17
+ def op_hint(input: Any, hint: str) -> Hinted[Any]:
18
+ return Hinted(hint, input)
19
+
20
+
21
+ def op_now() -> str:
22
+ return datetime.now().isoformat(timespec="milliseconds") + "Z"
23
+
24
+
25
+ def op_try(ctx: DuqContext, input: Any, body: Expr) -> Any:
26
+ try:
27
+ return ctx.evaluate(body, input)
28
+ except:
29
+ return None
30
+
31
+
32
+ op_definitions = {
33
+ "std.basic.id": op_id,
34
+ "std.basic.eq": op_eq,
35
+ "std.basic.hint": op_hint,
36
+ "std.time.now": op_now,
37
+ "std.basic.try": op_try,
38
+ }
@@ -0,0 +1,12 @@
1
+ def op_not(input: bool) -> bool:
2
+ return not input
3
+
4
+
5
+ def op_str(input: bool) -> str:
6
+ return str(input).lower()
7
+
8
+
9
+ op_definitions = {
10
+ "std.bool.not": op_not,
11
+ "std.bool.str": op_str,
12
+ }
@@ -0,0 +1,20 @@
1
+ from typing import Any
2
+
3
+
4
+ def op_tableToRecords(input: list[list[str]]) -> list[dict[str, str]]:
5
+ if len(input) == 0:
6
+ raise Exception("missing header row")
7
+ headers = input[0]
8
+ rows = input[1:]
9
+ result = []
10
+ for row in rows:
11
+ record = {}
12
+ for i, header in enumerate(headers):
13
+ record[header] = None if i >= len(row) else row[i]
14
+ result.append(record)
15
+ return result
16
+
17
+
18
+ op_definitions = {
19
+ "std.csv.tableToRecords": op_tableToRecords,
20
+ }
@@ -0,0 +1,8 @@
1
+ def op_read(input: str) -> str:
2
+ with open(input, "r") as f:
3
+ return f.read()
4
+
5
+
6
+ op_definitions = {
7
+ "std.file.read": op_read,
8
+ }
@@ -0,0 +1,33 @@
1
+ import asyncio
2
+ from typing import Any
3
+
4
+ from duq._evaluation import DuqContext
5
+ from duq._syntax import Expr
6
+ from duq._util import DuqFuture
7
+
8
+
9
+ def op_sleep(seconds: int | float) -> DuqFuture[None]:
10
+ return DuqFuture(lambda: asyncio.sleep(seconds))
11
+
12
+
13
+ def op_map(ctx: DuqContext, input: DuqFuture[Any], body: Expr) -> DuqFuture[Any]:
14
+ async def task():
15
+ value = await input.create()
16
+ return ctx.evaluate(body, value)
17
+
18
+ return DuqFuture(task)
19
+
20
+
21
+ def op_all(input: list[DuqFuture[Any]]) -> DuqFuture[list[Any]]:
22
+ async def task():
23
+ children = [child.create() for child in input]
24
+ return await asyncio.gather(*children)
25
+
26
+ return DuqFuture(task)
27
+
28
+
29
+ op_definitions = {
30
+ "std.future.sleep": op_sleep,
31
+ "std.future.map": op_map,
32
+ "std.future.all": op_all,
33
+ }
@@ -0,0 +1,20 @@
1
+ import bs4
2
+
3
+
4
+ def op_html(input: str) -> bs4.BeautifulSoup:
5
+ return bs4.BeautifulSoup(input, "html.parser")
6
+
7
+
8
+ def op_select(input: bs4.Tag, query: str) -> list[bs4.Tag]:
9
+ return list(input.select(query))
10
+
11
+
12
+ def op_text(input: bs4.Tag) -> str:
13
+ return input.text
14
+
15
+
16
+ op_definitions = {
17
+ "std.html.html": op_html,
18
+ "std.html.select": op_select,
19
+ "std.html.text": op_text,
20
+ }
@@ -0,0 +1,25 @@
1
+ import httpx
2
+
3
+ from duq._evaluation import DuqContext
4
+ from duq._util import DuqFuture
5
+
6
+
7
+ def op_fetch(ctx: DuqContext, input: str) -> DuqFuture[str]:
8
+ async def task() -> str:
9
+ async with httpx.AsyncClient() as client:
10
+ response = await client.get(
11
+ input,
12
+ headers={
13
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
14
+ },
15
+ )
16
+ if response.status_code != 200:
17
+ raise Exception(f"request error: {input} -> {response.status_code}")
18
+ return response.text
19
+
20
+ return DuqFuture(task).cached(ctx, ["std.http.fetch", input])
21
+
22
+
23
+ op_definitions = {
24
+ "std.http.fetch": op_fetch,
25
+ }
@@ -0,0 +1,16 @@
1
+ import json
2
+ from typing import Any
3
+
4
+
5
+ def op_json(input: str) -> Any:
6
+ return json.loads(input)
7
+
8
+
9
+ def op_pretty(input: Any) -> str:
10
+ return json.dumps(input, indent=2)
11
+
12
+
13
+ op_definitions = {
14
+ "std.json.json": op_json,
15
+ "std.json.pretty": op_pretty,
16
+ }