diff --git a/.gitignore b/.gitignore index 74feaa3..cc14f04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Widip +*.jpg + # VS code .vscode @@ -64,6 +67,7 @@ cover/ # Django stuff: *.log +!tests/*.log local_settings.py db.sqlite3 db.sqlite3-journal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8d98ec7 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +JPG_FILES := $(shell git ls-files '*.jpg') +YAML_FILES := $(JPG_FILES:.jpg=.yaml) + +.PHONY: all clean + +all: $(JPG_FILES) + +%.jpg: %.yaml + @echo "Generating $@..." + @echo $< | bin/yaml/shell.yaml + +clean: + rm -f $(JPG_FILES) diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 0000000..99adc05 --- /dev/null +++ b/bin/README.md @@ -0,0 +1,24 @@ +# Execution Model + +In `widip`, YAML tags such as `!eval`, `!read`, or `!print` are interpreted as **commands to execute**. + +## Using Executables + +You can use **any executable** that exists in your system's `$PATH` or by providing a relative/absolute path. + +**Examples:** + +* **System tools:** `!python`, `!grep`, `!awk`. +* **Custom scripts:** `!./myscript.sh`. + +## Using Other YAML Files + +You can also use other `widip` YAML files as commands, provided they are executable (e.g., they have a valid shebang like `#!bin/widish`). This allows you to compose complex pipelines from smaller, reusable diagrams. + +**Example:** + +```yaml +- !executable.yaml +``` + +This will execute the `executable.yaml` diagram as a step in your current pipeline. diff --git a/bin/widish b/bin/widish new file mode 100755 index 0000000..4f6869a --- /dev/null +++ b/bin/widish @@ -0,0 +1,2 @@ +#!/bin/sh +exec python -O -m widip "$@" diff --git a/bin/yaml/python.yaml b/bin/yaml/python.yaml new file mode 100755 index 0000000..6dd6fdf --- /dev/null +++ b/bin/yaml/python.yaml @@ -0,0 +1,2 @@ +#!bin/widish +!python diff --git a/bin/yaml/range.yaml b/bin/yaml/range.yaml new file mode 100755 index 0000000..d53f03d --- /dev/null +++ b/bin/yaml/range.yaml @@ -0,0 +1,2 @@ +#!/bin/widish +!seq { 1, 100 } diff --git a/examples/README.md b/examples/README.md index ac92f6a..bf0f548 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,6 +9,8 @@ Hello world! ![](hello-world.jpg) +![](hello-world.shell.jpg) + ## Script ``` @@ -21,6 +23,8 @@ $ python -m widip examples/shell.yaml ![IMG](shell.jpg) +![IMG](shell.shell.jpg) + # Working with the CLI Open terminal and run `widip` to start an interactive session. The program `bin/yaml/shell.yaml` prompts for one command per line, so when we hit `↵ Enter` it is evaluated. When hitting `⌁ Ctrl+D` the environment exits. diff --git a/examples/aoc2025/1-1.jpg b/examples/aoc2025/1-1.jpg deleted file mode 100644 index 3ef213f..0000000 Binary files a/examples/aoc2025/1-1.jpg and /dev/null differ diff --git a/examples/hello-world.jpg b/examples/hello-world.jpg index 382ad43..d1df589 100644 Binary files a/examples/hello-world.jpg and b/examples/hello-world.jpg differ diff --git a/examples/hello-world.shell.jpg b/examples/hello-world.shell.jpg new file mode 100644 index 0000000..d215758 Binary files /dev/null and b/examples/hello-world.shell.jpg differ diff --git a/examples/shell.jpg b/examples/shell.jpg index da94402..9e1065d 100644 Binary files a/examples/shell.jpg and b/examples/shell.jpg differ diff --git a/examples/shell.shell.jpg b/examples/shell.shell.jpg new file mode 100644 index 0000000..d237214 Binary files /dev/null and b/examples/shell.shell.jpg differ diff --git a/examples/shell.yaml.jpg b/examples/shell.yaml.jpg new file mode 100644 index 0000000..305953d Binary files /dev/null and b/examples/shell.yaml.jpg differ diff --git a/pyproject.toml b/pyproject.toml index 12389de..33380d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,12 @@ name = "widip" version = "0.1.0" description = "Widip is an interactive environment for computing with wiring diagrams in modern systems" dependencies = [ - "discopy>=1.2.1", "pyyaml>=6.0.1", "watchdog>=4.0.1", "nx-yaml==0.3.0", + "discopy>=1.2.2", "watchfiles>=1.1.1", "nx-yaml>=1.0.0", +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-asyncio", ] -[project.urls] -"Source" = "https://github.com/colltoaction/widip" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a570e0b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..92ef103 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,27 @@ +# Widish Tests + +This directory contains test cases for the `widish` shell environment. `widish` combines the familiarity of shell commands with the structure and composability of YAML. + +## What is Widish? + +`widish` allows you to write shell scripts as YAML documents. Data flows through the structure, enabling: + +- **Structured Pipelines**: Use YAML sequences (lists) to pipe data between commands. +- **Structured Data**: Pass structured data (like YAML mappings) between processes, not just text streams. +- **Composition**: Reuse YAML files as scripts/commands within other YAML scripts. +- **Implicit Parallelism**: Use mappings to branch data flow to multiple commands simultaneously. + +## Running Tests + +Tests are run using `pytest` and the `tests/test_harness.py` script. The harness executes each `.test.yaml` file using `bin/widish` and compares the standard output to the corresponding `.log` file. + +```bash +pytest tests/test_harness.py +``` + +## Test Case Format + +Each test case consists of two files: + +1. `tests/CASE.test.yaml`: The input YAML script. +2. `tests/CASE.log`: The expected standard output. diff --git a/tests/fan-out.log b/tests/fan-out.log new file mode 100644 index 0000000..c005939 --- /dev/null +++ b/tests/fan-out.log @@ -0,0 +1,2 @@ +2 +WIDIP STUFF diff --git a/tests/fan-out.test.yaml b/tests/fan-out.test.yaml new file mode 100644 index 0000000..3ce3225 --- /dev/null +++ b/tests/fan-out.test.yaml @@ -0,0 +1,4 @@ +- !echo { "widip", "project" } +- !tr { "[:lower:]", "[:upper:]" } +- ? !wc -w + ? !sed "s/PROJECT/STUFF/" diff --git a/tests/git-first-commit.log b/tests/git-first-commit.log new file mode 100644 index 0000000..ae1ec20 --- /dev/null +++ b/tests/git-first-commit.log @@ -0,0 +1,2 @@ +? !id 8f20e0d66c3a6587ccd484642cea4d5db9eb9756 +? !date "Tue Feb 6 12:12:01 2024 -0300" diff --git a/tests/git-first-commit.test.yaml b/tests/git-first-commit.test.yaml new file mode 100644 index 0000000..96c8a50 --- /dev/null +++ b/tests/git-first-commit.test.yaml @@ -0,0 +1,3 @@ +!git { log, "--max-parents=0" }: + !grep commit: !sed "s/commit /? !id /" + !grep Date: !sed "s/Date: /? !date \"/;s/$/\"/" diff --git a/tests/infinite-counter.log b/tests/infinite-counter.log new file mode 100644 index 0000000..8a1218a --- /dev/null +++ b/tests/infinite-counter.log @@ -0,0 +1,5 @@ +1 +2 +3 +4 +5 diff --git a/tests/infinite-counter.test.yaml b/tests/infinite-counter.test.yaml new file mode 100644 index 0000000..8360e21 --- /dev/null +++ b/tests/infinite-counter.test.yaml @@ -0,0 +1,2 @@ +- !bin/yaml/range.yaml +- !head { -n, 5 } diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..4d7309e --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,37 @@ +import pytest +import subprocess +import glob +import os + +# Find all test cases +TEST_DIR = os.path.dirname(__file__) +TEST_CASES = sorted(glob.glob(os.path.join(TEST_DIR, "*.test.yaml"))) + +@pytest.mark.parametrize("test_file", TEST_CASES) +def test_case(test_file): + # Determine the log file path + log_file = test_file.replace(".test.yaml", ".log") + + # Check if log file exists + assert os.path.exists(log_file), f"Log file missing for {test_file}" + + # Read input and expected output + with open(test_file, "r") as f: + input_content = f.read() + + with open(log_file, "r") as f: + expected_output = f.read() + + # Run the shell + # Assuming running from repo root + cmd = ["bin/widish", test_file] + + result = subprocess.run( + cmd, + text=True, + capture_output=True, + check=False + ) + + # Assert output + assert result.stdout == expected_output diff --git a/widip/__main__.py b/widip/__main__.py index f1a9866..f691900 100644 --- a/widip/__main__.py +++ b/widip/__main__.py @@ -1,13 +1,46 @@ -import sys +if __debug__: + # Non-interactive backend for file output + import matplotlib + matplotlib.use('agg') -# Stop starting a Matplotlib GUI -import matplotlib -matplotlib.use('agg') +import argparse +import asyncio -from .watch import shell_main, widish_main +from .interactive import async_shell_main, async_widish_main, async_command_main +from .watch import run_with_watcher -match sys.argv: - case [_]: - shell_main("bin/yaml/shell.yaml") - case [_, file_name, *args]: widish_main(file_name, *args) +def main(): + parser = argparse.ArgumentParser( + description="Widip: an interactive environment for computing with wiring diagrams" + ) + parser.add_argument( + "-c", + dest="command_string", + help="read commands from the first non-option argument" + ) + parser.add_argument( + "operands", + nargs=argparse.REMAINDER, + help="[command_string | file] [arguments...]" + ) + + args = parser.parse_args() + + try: + if args.command_string is not None: + asyncio.run(async_command_main(args.command_string, *args.operands)) + elif args.operands: + file_name = args.operands[0] + file_args = args.operands[1:] + asyncio.run(async_widish_main(file_name, *file_args)) + else: + async_shell_runner = async_shell_main("bin/yaml/shell.yaml") + interactive_shell = run_with_watcher(async_shell_runner) + asyncio.run(interactive_shell) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/widip/compiler.py b/widip/compiler.py new file mode 100644 index 0000000..57f54ec --- /dev/null +++ b/widip/compiler.py @@ -0,0 +1,56 @@ +from discopy import closed + +from .computer import * +from .yaml import * + + +def compile_ar(ar): + if isinstance(ar, Scalar): + if ar.tag == "exec": + return Exec(ar.dom, ar.cod) + if ar.tag: + return Program(ar.tag, dom=ar.dom, cod=ar.cod).uncurry() + return Data(ar.dom, ar.cod) + if isinstance(ar, Sequence): + inner_diagram = ar.args[0] + inside_compiled = SHELL_COMPILER(inner_diagram) + + if ar.dom[:1] == Language: + return inside_compiled + + if ar.n == 2: + return inside_compiled >> Pair(inside_compiled.cod, ar.cod) + + return inside_compiled >> Sequential(inside_compiled.cod, ar.cod) + + if isinstance(ar, Mapping): + inner_diagram = ar.args[0] + inside_compiled = SHELL_COMPILER(inner_diagram) + return inside_compiled >> Concurrent(inside_compiled.cod, ar.cod) + + if isinstance(ar, Alias): + return Data(ar.dom, ar.cod) >> Copy(ar.cod, 2) >> closed.Id(ar.cod) @ Discard(ar.cod) + if isinstance(ar, Anchor): + return Copy(ar.dom, 2) >> closed.Id(ar.dom) @ Discard(ar.dom) + return ar + + +class ShellFunctor(closed.Functor): + def __init__(self): + super().__init__( + lambda ob: ob, + compile_ar, + dom=Yaml, + cod=Computation + ) + +SHELL_COMPILER = ShellFunctor() + + +def compile_shell_program(diagram): + """ + close input parameters (constants) + drop outputs matching input parameters + all boxes are io->[io]""" + diagram = SHELL_COMPILER(diagram) + return diagram diff --git a/widip/composing.py b/widip/composing.py deleted file mode 100644 index 7464cc5..0000000 --- a/widip/composing.py +++ /dev/null @@ -1,145 +0,0 @@ -from discopy.closed import Id, Ty, Diagram, Functor, Box - - -def adapt_to_interface(diagram, box): - return - """adapts a diagram open ports to fit in the box""" - left = Id(box.dom) - right = Id(box.cod) - return adapter_hypergraph(left, diagram) >> \ - diagram >> \ - adapter_hypergraph(diagram, right) - -def adapter_hypergraph(left, right): - return - mid = Ty().tensor(*set(left.cod + right.dom)) - mid_to_left_ports = { - t: tuple(i for i, lt in enumerate(left.cod) if lt == t) - for t in mid} - mid_to_right_ports = { - t: tuple(i + len(left.cod) for i, lt in enumerate(right.dom) if lt == t) - for t in mid} - boxes = tuple( - Id(Ty().tensor(*(t for _ in range(len(mid_to_left_ports[t]))))) - if len(mid_to_left_ports[t]) == len(mid_to_right_ports[t]) else - Spider( - len(mid_to_left_ports[t]), - len(mid_to_right_ports[t]), - t) - for t in mid) - g = H( - dom=left.cod, cod=right.dom, - boxes=boxes, - wires=( - tuple(i for i in range(len(left.cod))), - tuple( - (mid_to_left_ports[t], mid_to_right_ports[t]) - for t in mid), - tuple(i + len(left.cod) for i in range(len(right.dom))), - ), - ) - return g.to_diagram() - -def glue_diagrams(left, right): - return - """a diagram connecting equal objects within each type""" - """glues two diagrams sequentially with closed generators""" - if left.cod == right.dom: - return left >> right - l_dom, l_cod, r_dom, r_cod = left.dom, left.cod, right.dom, right.cod - dw_l = { - t - for t in l_cod - if t not in r_dom} - dw_r = { - t - for t in r_dom - if t not in l_cod} - cw_l = { - t - for t in l_cod - if t in r_dom} - cw_r = { - t - for t in r_dom - if t in l_cod} - # TODO convention for repeated in both sides - mid_names = tuple({t for t in l_cod + r_dom}) - dom_wires = l_dom_wires = tuple( - i - for i in range(len(l_dom) + len(dw_r)) - ) - l_cod_wires = tuple( - (mid_names.index(t) - + len(l_dom) + len(dw_r)) - for t in l_cod) + \ - tuple( - (mid_names.index(t) + len(l_dom) + len(dw_r)) - for t in dw_r - ) - r_dom_wires = tuple( - (mid_names.index(t) + len(l_dom) + len(dw_r)) - for t in dw_l) + \ - tuple( - (mid_names.index(t) - + len(l_dom) + len(dw_r)) - for t in r_dom - ) - cod_wires = r_cod_wires = tuple( - i - + len(l_dom) + len(dw_r) - + len(mid_names) - for i in range(len(dw_l) + len(r_cod)) - ) - glued = H( - dom=l_dom @ Ty().tensor(*dw_r), - cod=Ty().tensor(*dw_l) @ r_cod, - boxes=( - left @ Ty().tensor(*dw_r), - Ty().tensor(*dw_l) @ right, - ), - wires=( - dom_wires, - ( - (l_dom_wires, l_cod_wires), - (r_dom_wires, r_cod_wires), - ), - cod_wires, - ), - ).to_diagram() - return glued - -def replace_id_f(name): - return Functor( - lambda ob: replace_id_ty(ob, name), - lambda ar: replace_id_box(ar, name),) - -def replace_id_box(box, name): - return Box( - box.name, - replace_id_ty(box.dom, name), - replace_id_ty(box.cod, name)) - -def replace_id_ty(ty, name): - return Ty().tensor(*(Ty("") if t == Ty(name) else t for t in ty)) - -def close_ty_f(name): - return Functor( - lambda ob: ob,#close_ty(ob, name), - lambda ar: close_ty_box(ar, name),) - -def close_ty_box(box, name): - l = Ty().tensor(*( - t for t in box.dom - if t != Ty(name))) - r = Ty().tensor(*( - t for t in box.cod - if t != Ty(name))) - # box.draw() - box.draw() - closed = adapt_to_interface(box, Box("", l, r)) - closed.draw() - return closed - -def close_ty(ty, name): - return Ty() if ty == Ty(name) else ty \ No newline at end of file diff --git a/widip/computer.py b/widip/computer.py new file mode 100644 index 0000000..76d625e --- /dev/null +++ b/widip/computer.py @@ -0,0 +1,82 @@ +""" +This module implements the computational model described in "Programs as Diagrams" (arXiv:2208.03817). +It defines the core boxes (Data, Sequential, Concurrent) representing the computation category. +""" + +from discopy import closed, symmetric, markov, traced + + +Language = closed.Ty("IO") + +class Eval(closed.Box): + def __init__(self, A, B): + drawing_name = "{}" + f": {A} -> {B}" + super().__init__("", Language @ A, B, drawing_name=drawing_name) + +class Program(closed.Box): + def __init__(self, name, dom=None, cod=None): + self.target_dom = dom + self.target_cod = cod + super().__init__(name, closed.Ty(), Language) + + def uncurry(self, left=True): + return self @ closed.Id(self.target_dom) >> Eval(self.target_dom, self.target_cod) + +class Constant(closed.Box): + def __init__(self, cod=None): + super().__init__("Γ", closed.Ty(), Language) + +class Data(closed.Box): + def __init__(self, dom, cod): + drawing_name = f"⌜{dom[0].name}⌝" if dom else "⌜-⌝" + super().__init__("⌜-⌝", dom, cod, drawing_name=drawing_name) + +class Sequential(closed.Box): + def __init__(self, dom, cod): + super().__init__("(;)", dom, cod) + +class Concurrent(closed.Box): + def __init__(self, dom, cod): + super().__init__("(||)", dom, cod) + +class Pair(closed.Box): + def __init__(self, dom, cod): + super().__init__("⌈−,−⌉", dom, cod) + +class Cast(closed.Box): + def __init__(self, dom, cod): + super().__init__("Cast", dom, cod) + +class Swap(closed.Box, symmetric.Swap): + def __init__(self, left, right): + symmetric.Swap.__init__(self, left, right) + self.name = "σ" + +class Copy(closed.Box, markov.Copy): + def __init__(self, x, n=2): + if len(x) == 1: + markov.Copy.__init__(self, x, n) + else: + name = f"Copy({x}" + ("" if n == 2 else f", {n}") + ")" + closed.Box.__init__(self, name, dom=x, cod=x ** n) + + self.n = n + +class Discard(closed.Box, markov.Discard): + def __init__(self, dom): + if len(dom) == 1: + markov.Discard.__init__(self, dom) + else: + name = f"Discard({dom})" + closed.Box.__init__(self, name, dom=dom, cod=closed.Ty()) + +class Trace(closed.Box, traced.Trace): + def __init__(self, arg, left=False): + traced.Trace.__init__(self, arg, left) + closed.Box.__init__(self, self.name, self.dom, self.cod) + +class Exec(closed.Box): + def __init__(self, dom, cod): + super().__init__("exec", dom, cod) + +Computation = closed.Category(closed.Ty, closed.Diagram) diff --git a/widip/files.py b/widip/files.py index be33b80..749b9af 100644 --- a/widip/files.py +++ b/widip/files.py @@ -1,10 +1,27 @@ -import pathlib +import sys +from pathlib import Path +from yaml import YAMLError -from discopy.closed import Ty, Diagram, Box, Id, Functor +from discopy.closed import Ty, Diagram, Box, Functor -from .loader import repl_read +from nx_yaml import nx_compose_all +from .loader import incidences_to_diagram +def repl_read(stream): + incidences = nx_compose_all(stream) + return incidences_to_diagram(incidences) + + +def reload_diagram(path_str): + print(f"reloading {path_str}", file=sys.stderr) + try: + fd = file_diagram(path_str) + diagram_draw(Path(path_str), fd) + diagram_draw(Path(path_str+".2"), fd) + except YAMLError as e: + print(e, file=sys.stderr) + def files_ar(ar: Box) -> Diagram: """Uses IO to read a file or dir with the box name as path""" if not ar.name.startswith("file://"): @@ -17,10 +34,8 @@ def files_ar(ar: Box) -> Diagram: return ar def file_diagram(file_name) -> Diagram: - path = pathlib.Path(file_name) + path = Path(file_name) fd = repl_read(path.open()) - # TODO TypeError: Expected closed.Diagram, got monoidal.Diagram instead - # fd = replace_id_f(path.stem)(fd) return fd def diagram_draw(path, fd): diff --git a/widip/hif.py b/widip/hif.py new file mode 100644 index 0000000..0ac15b7 --- /dev/null +++ b/widip/hif.py @@ -0,0 +1,113 @@ +from nx_hif.hif import hif_node_incidences, hif_edge_incidences, hif_node +from discopy.frobenius import Hypergraph, Box, Ty + +from .traverse import vertical_map, get_base, get_fiber, FoliatedObject + + +def get_node_data(cursor: FoliatedObject) -> dict: + """Returns the data associated with the node at the cursor's position.""" + node = get_base(cursor) + index = get_fiber(cursor) + return hif_node(node, index) + +def to_hif(hg: Hypergraph) -> dict: + """Serializes a DisCoPy Hypergraph to a dictionary-based HIF format""" + nodes = {} + + spider_types = hg.spider_types + if isinstance(spider_types, (list, tuple)): + iterator = enumerate(spider_types) + else: + iterator = spider_types.items() + + for wire_id, t in iterator: + type_name = t.name if t else "" + nodes[str(wire_id)] = {"type": type_name} + + edges = [] + box_wires = hg.wires[1] + for i, box in enumerate(hg.boxes): + sources = [str(x) for x in box_wires[i][0]] + targets = [str(x) for x in box_wires[i][1]] + + edges.append({ + "box": { + "name": box.name, + "dom": [x.name for x in box.dom], + "cod": [x.name for x in box.cod], + "data": box.data + }, + "sources": sources, + "targets": targets + }) + + dom_wires = [str(x) for x in hg.wires[0]] + cod_wires = [str(x) for x in hg.wires[2]] + + return { + "nodes": nodes, + "edges": edges, + "dom": dom_wires, + "cod": cod_wires + } + +def from_hif(data: dict) -> Hypergraph: + """ Reconstructs a DisCoPy Hypergraph from the dictionary-based HIF format""" + sorted_node_ids = sorted(data["nodes"].keys()) + id_map = {nid: i for i, nid in enumerate(sorted_node_ids)} + + spider_types = {} + for nid in sorted_node_ids: + t_name = data["nodes"][nid]["type"] + spider_types[id_map[nid]] = Ty(t_name) if t_name else Ty() + + boxes = [] + box_wires_list = [] + + for edge in data["edges"]: + sources = [id_map[s] for s in edge["sources"]] + targets = [id_map[t] for t in edge["targets"]] + + dom_types = [spider_types[i] for i in sources] + cod_types = [spider_types[i] for i in targets] + + dom = Ty().tensor(*dom_types) + cod = Ty().tensor(*cod_types) + + b_spec = edge["box"] + box = Box(b_spec["name"], dom, cod, data=b_spec.get("data")) + boxes.append(box) + box_wires_list.append((tuple(sources), tuple(targets))) + + dom_wires = [id_map[s] for s in data["dom"]] + cod_wires = [id_map[s] for s in data["cod"]] + + wires = (tuple(dom_wires), tuple(box_wires_list), tuple(cod_wires)) + dom = Ty().tensor(*[spider_types[i] for i in dom_wires]) + cod = Ty().tensor(*[spider_types[i] for i in cod_wires]) + + return Hypergraph(dom, cod, boxes, wires, spider_types=spider_types) + + +def step(cursor: FoliatedObject, key: str) -> FoliatedObject | None: + """Advances the cursor along a specific edge key (e.g., 'next', 'forward').""" + node = get_base(cursor) + index = get_fiber(cursor) + + incidences = tuple(hif_node_incidences(node, index, key=key)) + if not incidences: + return None + ((edge, _, _, _), ) = incidences + start = tuple(hif_edge_incidences(node, edge, key="start")) + if not start: + return None + ((_, neighbor, _, _), ) = start + + return vertical_map(cursor, lambda _: neighbor) + +def iterate(cursor: FoliatedObject): + """Yields a sequence of cursors by following 'next' then 'forward' edges.""" + curr = step(cursor, "next") + while curr: + yield curr + curr = step(curr, "forward") diff --git a/widip/interactive.py b/widip/interactive.py new file mode 100644 index 0000000..3849948 --- /dev/null +++ b/widip/interactive.py @@ -0,0 +1,120 @@ +import asyncio +import sys +import inspect +from pathlib import Path +from yaml import YAMLError + +from discopy.utils import tuplify + +from .files import file_diagram, repl_read +from .widish import SHELL_RUNNER +from .compiler import SHELL_COMPILER +from .thunk import unwrap, force_execution, flatten, is_awaitable + + +async def apply_inp(r, val): + if is_awaitable(r): + r = await unwrap(r) + + if callable(r): + res = r(val) + return await unwrap(res) + return r + +async def run_process(runner, inp): + if isinstance(runner, tuple): + lazy_result = await asyncio.gather(*(apply_inp(r, inp) for r in runner)) + return tuple(lazy_result) + else: + return await apply_inp(runner, inp) + +async def async_exec_diagram(yaml_d, path, *shell_program_args): + loop = asyncio.get_running_loop() + + if __debug__ and path is not None: + from .files import diagram_draw + diagram_draw(path, yaml_d) + + constants = tuple(x.name for x in yaml_d.dom) + compiled_d = SHELL_COMPILER(yaml_d) + + if __debug__ and path is not None: + from .files import diagram_draw + diagram_draw(path.with_suffix(".shell.yaml"), compiled_d) + + # Run the shell runner to get the computation process/function + process_result = await unwrap(SHELL_RUNNER(compiled_d)(*constants)) + runner = process_result + + if sys.stdin.isatty(): + inp = "" + else: + inp = await loop.run_in_executor(None, sys.stdin.read) + + lazy_result = await run_process(runner, inp) + + # Force execution of the Task(s) + val = await force_execution(lazy_result) + + print(*(tuple(x.rstrip() for x in flatten(tuplify(val)) if x)), sep="\n") + + +async def async_command_main(command_string, *shell_program_args): + fd = repl_read(command_string) + # No file path associated with command string + await async_exec_diagram(fd, None, *shell_program_args) + + +async def async_widish_main(file_name, *shell_program_args): + fd = file_diagram(file_name) + path = Path(file_name) + await async_exec_diagram(fd, path, *shell_program_args) + + +async def async_shell_main(file_name): + path = Path(file_name) + loop = asyncio.get_running_loop() + + while True: + try: + if not sys.stdin.isatty(): + source = await loop.run_in_executor(None, sys.stdin.read) + if not source: + break + else: + prompt = f"--- !{file_name}\n" + source = await loop.run_in_executor(None, input, prompt) + + yaml_d = repl_read(source) + if __debug__: + from .files import diagram_draw + diagram_draw(path, yaml_d) + source_d = SHELL_COMPILER(yaml_d) + compiled_d = source_d + # compiled_d = SHELL_COMPILER(source_d) + # if __debug__: + # diagram_draw(path.with_suffix(".shell.yaml"), compiled_d) + constants = tuple(x.name for x in compiled_d.dom) + + # Execute the runner to get the lazy result + process_result = await unwrap(SHELL_RUNNER(compiled_d)(*constants)) + + # Use empty input for interactive commands if they don't expect stdin? + # Or pass "" as before. + lazy_result = await run_process(process_result, "") + + # Force execution + result = await force_execution(lazy_result) + + print(*(tuple(x.rstrip() for x in flatten(tuplify(result)) if x)), sep="\n") + + if not sys.stdin.isatty(): + break + except EOFError: + if sys.stdin.isatty(): + print("⌁", file=sys.stderr) + break + except KeyboardInterrupt: + print(file=sys.stderr) + except YAMLError as e: + print(e, file=sys.stderr) diff --git a/widip/loader.py b/widip/loader.py index 912e6cc..fb568f7 100644 --- a/widip/loader.py +++ b/widip/loader.py @@ -1,144 +1,115 @@ -from itertools import batched -from nx_yaml import nx_compose_all, nx_serialize_all -from nx_hif.hif import * - -from discopy.markov import Id, Ty, Box, Eval -P = Ty("io") >> Ty("io") - -from .composing import glue_diagrams - - -def repl_read(stream): - incidences = nx_compose_all(stream) - diagrams = incidences_to_diagram(incidences) - return diagrams - -def incidences_to_diagram(node: HyperGraph): - # TODO properly skip stream and document start - diagram = _incidences_to_diagram(node, 0) - return diagram - -def _incidences_to_diagram(node: HyperGraph, index): - """ - Takes an nx_yaml rooted bipartite graph - and returns an equivalent string diagram - """ - tag = (hif_node(node, index).get("tag") or "")[1:] - kind = hif_node(node, index)["kind"] - - match kind: - - case "stream": - ob = load_stream(node, index) - case "document": - ob = load_document(node, index) - case "scalar": - ob = load_scalar(node, index, tag) - case "sequence": - ob = load_sequence(node, index, tag) - case "mapping": - ob = load_mapping(node, index, tag) - case _: - raise Exception(f"Kind \"{kind}\" doesn't match any.") - - return ob - - -def load_scalar(node, index, tag): - v = hif_node(node, index)["value"] - if tag and v: - return Box("G", Ty(tag) @ Ty(v), Ty() << Ty("")) - elif tag: - return Box("G", Ty(tag), Ty() << Ty("")) - elif v: - return Box("⌜−⌝", Ty(v), Ty() << Ty("")) - else: - return Box("⌜−⌝", Ty(), Ty() << Ty("")) - -def load_mapping(node, index, tag): - ob = Id() - i = 0 - nxt = tuple(hif_node_incidences(node, index, key="next")) - while True: - if not nxt: - break - ((k_edge, _, _, _), ) = nxt - ((_, k, _, _), ) = hif_edge_incidences(node, k_edge, key="start") - ((v_edge, _, _, _), ) = hif_node_incidences(node, k, key="forward") - ((_, v, _, _), ) = hif_edge_incidences(node, v_edge, key="start") - key = _incidences_to_diagram(node, k) - value = _incidences_to_diagram(node, v) - - kv = key @ value - - if i==0: - ob = kv - else: - ob = ob @ kv - - i += 1 - nxt = tuple(hif_node_incidences(node, v, key="forward")) - bases = Ty().tensor(*map(lambda x: x.inside[0].base, ob.cod[0::2])) - exps = Ty().tensor(*map(lambda x: x.inside[0].exponent, ob.cod[1::2])) - par_box = Box("(||)", ob.cod, exps << bases) - ob = ob >> par_box - if tag: - ob = (ob @ bases>> Eval(exps << bases)) - ob = Ty(tag) @ ob >> Box("G", Ty(tag) @ ob.cod, Ty("") << Ty("")) - return ob - -def load_sequence(node, index, tag): - ob = Id() - i = 0 - nxt = tuple(hif_node_incidences(node, index, key="next")) - while True: - if not nxt: - break - ((v_edge, _, _, _), ) = nxt - ((_, v, _, _), ) = hif_edge_incidences(node, v_edge, key="start") - value = _incidences_to_diagram(node, v) - if i==0: - ob = value - else: - ob = ob @ value - bases = ob.cod[0].inside[0].exponent - exps = value.cod[0].inside[0].base - ob = ob >> Box("(;)", ob.cod, bases >> exps) - - i += 1 - nxt = tuple(hif_node_incidences(node, v, key="forward")) - if tag: - bases = Ty().tensor(*map(lambda x: x.inside[0].exponent, ob.cod)) - exps = Ty().tensor(*map(lambda x: x.inside[0].base, ob.cod)) - ob = (bases @ ob >> Eval(bases >> exps)) - ob = Ty(tag) @ ob >> Box("G", Ty(tag) @ ob.cod, Ty() >> Ty(tag)) - return ob - -def load_document(node, index): - nxt = tuple(hif_node_incidences(node, index, key="next")) - ob = Id() - if nxt: - ((root_e, _, _, _), ) = nxt - ((_, root, _, _), ) = hif_edge_incidences(node, root_e, key="start") - ob = _incidences_to_diagram(node, root) - return ob - -def load_stream(node, index): - ob = Id() - nxt = tuple(hif_node_incidences(node, index, key="next")) - while True: - if not nxt: - break - ((nxt_edge, _, _, _), ) = nxt - starts = tuple(hif_edge_incidences(node, nxt_edge, key="start")) - if not starts: - break - ((_, nxt_node, _, _), ) = starts - doc = _incidences_to_diagram(node, nxt_node) - if ob == Id(): - ob = doc - else: - ob = glue_diagrams(ob, doc) - - nxt = tuple(hif_node_incidences(node, nxt_node, key="forward")) - return ob +from functools import reduce + +from contextlib import contextmanager +from contextvars import ContextVar +from itertools import batched + +from discopy import closed +from nx_hif.hif import HyperGraph + +from . import hif +from .yaml import * + +diagram_var: ContextVar[closed.Diagram] = ContextVar("diagram") + +@contextmanager +def load_container(ob): + token = diagram_var.set(ob) + try: + yield + finally: + diagram_var.reset(token) + +def process_sequence(ob, tag): + if tag: + target = ob.cod + exps, bases = get_exps_bases(target) + ob = exps @ ob + ob >>= closed.Eval(target) + ob >>= Node(tag, ob.cod, closed.Ty() >> closed.Ty(tag)) + return ob + +def process_mapping(ob, tag): + # Mapping bubble is already applied before calling this + if tag: + target = ob.cod + exps, bases = get_exps_bases(target) + ob @= exps + ob >>= closed.Eval(target) + ob >>= Node(tag, ob.cod, closed.Ty(tag) >> closed.Ty(tag)) + return ob + +def load_scalar(cursor, tag): + data = hif.get_node_data(cursor) + return Scalar(tag, data["value"]) + +def load_sequence(cursor, tag): + diagrams = map(_incidences_to_diagram, hif.iterate(cursor)) + items = list(diagrams) + + if not items: + ob = Scalar(tag, "") + else: + diagram = reduce(lambda a, b: a @ b, items) + ob = Sequence(diagram) + ob = process_sequence(ob, tag) + + with load_container(ob): + return diagram_var.get() + +def load_mapping(cursor, tag): + diagrams = map(_incidences_to_diagram, hif.iterate(cursor)) + items = [] + + for key, val in batched(diagrams, 2): + pair = key @ val + pair = Sequence(pair, n=2) + items.append(pair) + + if not items: + ob = Scalar(tag, "") + else: + diagram = reduce(lambda a, b: a @ b, items) + ob = Mapping(diagram) + ob = process_mapping(ob, tag) + + with load_container(ob): + return diagram_var.get() + +def incidences_to_diagram(node: HyperGraph): + cursor = (0, node) + return _incidences_to_diagram(cursor) + +def _incidences_to_diagram(cursor): + data = hif.get_node_data(cursor) + tag = (data.get("tag") or "")[1:] + kind = data["kind"] + anchor = data.get("anchor") + + match kind: + case "stream": + ob = load_stream(cursor) + case "document": + ob = load_document(cursor) + case "scalar": + ob = load_scalar(cursor, tag) + case "sequence": + ob = load_sequence(cursor, tag) + case "mapping": + ob = load_mapping(cursor, tag) + case "alias": + ob = Alias(anchor, closed.Ty(), closed.Ty() >> closed.Ty(anchor)) + case _: + raise Exception(f"Kind \"{kind}\" doesn't match any.") + + if anchor and kind != 'alias': + ob >>= Anchor(anchor, ob.cod, ob.cod) + return ob + +def load_document(cursor): + root = hif.step(cursor, "next") + return _incidences_to_diagram(root) if root else closed.Id() + +def load_stream(cursor): + diagrams = map(_incidences_to_diagram, hif.iterate(cursor)) + return reduce(lambda a, b: a @ b, diagrams, closed.Id()) diff --git a/widip/test_compiler.py b/widip/test_compiler.py new file mode 100644 index 0000000..22e7d58 --- /dev/null +++ b/widip/test_compiler.py @@ -0,0 +1,81 @@ +import pytest +from discopy import closed +from .yaml import Sequence, Mapping, Scalar +from .compiler import SHELL_COMPILER +from .computer import Sequential, Pair, Concurrent, Data, Program, Exec, Eval + +# Helper to create dummy scalars for testing +def mk_scalar(name): + return Scalar(name, name) + +@pytest.mark.parametrize("input_bubble, expected_box_type", [ + # Case 1: Sequence (List) -> Sequential + ( + Sequence(mk_scalar("A") @ mk_scalar("B") @ mk_scalar("C")), + Sequential + ), + + # Case 2: Sequence (Pair, n=2) -> Pair + ( + Sequence(mk_scalar("A") @ mk_scalar("B")), + Pair + ), + + # Case 3: Mapping -> Concurrent + ( + Mapping(mk_scalar("K") @ mk_scalar("V")), + Concurrent + ), +]) +def test_compile_structure(input_bubble, expected_box_type): + compiled = SHELL_COMPILER(input_bubble) + last_box = compiled.boxes[-1] + assert isinstance(last_box, expected_box_type) + inner_compiled = SHELL_COMPILER(input_bubble.args[0]) + assert compiled == inner_compiled >> last_box + +def test_exec_compilation(): + # Test that Scalar with !exec tag compiles to Exec box + # !exec tag means tag="exec". + s = Scalar("exec", "ls") + c = SHELL_COMPILER(s) + + assert isinstance(c, Exec) + assert c.dom == closed.Ty("ls") + expected_cod = closed.Ty("exec") >> closed.Ty("exec") + assert c.cod == expected_cod + +@pytest.mark.parametrize("tag, name", [ + ("cat", "file.txt"), + ("echo", "hello"), +]) +def test_program_compilation(tag, name): + s = Scalar(tag, name) + c = SHELL_COMPILER(s) + # !cmd compiles to a Diagram involving Program and Eval + assert isinstance(c, closed.Diagram) + # It should contain a Program box with name=tag + boxes = c.boxes + programs = [b for b in boxes if isinstance(b, Program)] + assert len(programs) >= 1 + assert programs[0].name == tag + + # It should contain Eval box (implied by execution) + evals = [b for b in boxes if isinstance(b, Eval)] + assert len(evals) >= 1 + +def test_tr_compilation(): + # Test the specific case from aoc2025 + s = Scalar("tr", "{LR, -+}") + c = SHELL_COMPILER(s) + assert isinstance(c, closed.Diagram) + programs = [b for b in c.boxes if isinstance(b, Program)] + assert len(programs) >= 1 + assert programs[0].name == "tr" + # Check if arguments are handled? + # Arguments usually flow via wires or Data boxes. + # If "{LR, -+}" is the value of Scalar. + # It might be in the domain type or a Data box. + # Let's inspect domain + # dom should reflect the input value + assert c.dom == closed.Ty("{LR, -+}") diff --git a/widip/test_files.py b/widip/test_files.py deleted file mode 100644 index a5ffc60..0000000 --- a/widip/test_files.py +++ /dev/null @@ -1,37 +0,0 @@ -from discopy.closed import Box, Ty, Diagram, Id - -from .files import stream_diagram - - -def test_single_wires(): - a = Id("a") - a0 = stream_diagram("a") - a1 = stream_diagram("- a") - with Diagram.hypergraph_equality: - assert a == a0 - assert a0 == a1 - -def test_id_boxes(): - a = Box("a", Ty(""), Ty("")) - a0 = stream_diagram("!a") - a1 = stream_diagram("!a :") - a2 = stream_diagram("- !a") - with Diagram.hypergraph_equality: - assert a == a0 - assert a == a1 - assert a == a2 - -def test_the_empty_value(): - a0 = stream_diagram("") - a1 = stream_diagram("\"\":") - a2 = stream_diagram("\"\": a") - a3 = stream_diagram("a:") - a4 = stream_diagram("!a :") - a5 = stream_diagram("\"\": !a") - with Diagram.hypergraph_equality: - assert a0 == Id() - assert a1 == Id("") - assert a2 == Box("map", Ty(""), Ty("a")) - assert a3 == Id("a") - assert a4 == Box("a", Ty(""), Ty("")) - assert a5 == Box("map", Ty(""), Ty("")) >> a4 diff --git a/widip/test_hif.py b/widip/test_hif.py new file mode 100644 index 0000000..d0ba19d --- /dev/null +++ b/widip/test_hif.py @@ -0,0 +1,21 @@ +import pytest +from discopy.frobenius import Box, Ty, Swap, Id + +from .hif import to_hif, from_hif + + +@pytest.mark.parametrize("diagram", [ + Id(Ty('x')), + Id(Ty()), + Swap(Ty('x'), Ty('y')), + Box('f', Ty('x'), Ty('y')), + Box('f', Ty('x', 'y'), Ty('z')), + Box('f', Ty('x'), Ty('y')) >> Box('g', Ty('y'), Ty('z')), + Box('f', Ty('x'), Ty('y')) @ Box('g', Ty('z'), Ty('w')), + Box('f', Ty('x'), Ty('y')) @ Id(Ty('z')), +]) +def test_simple_cases(diagram): + hg = diagram.to_hypergraph() + data = to_hif(hg) + hg_new = from_hif(data) + assert hg == hg_new diff --git a/widip/test_interactive.py b/widip/test_interactive.py new file mode 100644 index 0000000..811fce1 --- /dev/null +++ b/widip/test_interactive.py @@ -0,0 +1,16 @@ +import subprocess +import pytest + +@pytest.mark.parametrize("filename, expected_output", [ + ("examples/hello-world.yaml", "Hello world!\n"), + ("examples/shell.yaml", "72\n22\n ? !grep grep: !wc -c\n ? !tail -2\n"), + ("examples/aoc2025/1-1.yaml", "1147\n"), +]) +def test_piping_to_widish(filename, expected_output, capfd): + with open(filename, "r") as f: + content = f.read() + + subprocess.run(["bin/widish"], input=content, text=True, check=False) + + out, err = capfd.readouterr() + assert expected_output == out diff --git a/widip/test_loader.py b/widip/test_loader.py deleted file mode 100644 index b98664e..0000000 --- a/widip/test_loader.py +++ /dev/null @@ -1,49 +0,0 @@ -from discopy.closed import Box, Ty, Diagram, Spider, Id, Spider - -from .loader import compose_all - - -id_box = lambda i: Box("!", Ty(i), Ty(i)) - -def test_tagged(): - a0 = compose_all("!a") - a1 = compose_all("!a :") - a2 = compose_all("--- !a") - a3 = compose_all("--- !a\n--- !b") - a4 = compose_all("\"\": !a") - a5 = compose_all("? !a") - with Diagram.hypergraph_equality: - assert a0 == Box("a", Ty(""), Ty("")) - assert a1 == a0 - assert a2 == a0 - assert a3 == a0 @ Box("b", Ty(""), Ty("")) - assert a4 == Box("map", Ty(""), Ty("")) >> a0 - assert a5 == a0 - -def test_untagged(): - a0 = compose_all("") - a1 = compose_all("\"\":") - a2 = compose_all("\"\": a") - a3 = compose_all("a:") - a4 = compose_all("? a") - with Diagram.hypergraph_equality: - assert a0 == Id() - assert a1 == Id("") - assert a2 == Box("map", Ty(""), Ty("a")) - assert a3 == Id("a") - assert a4 == a3 - -def test_bool(): - d = Id("true") @ Id("false") - t = compose_all(open("src/data/bool.yaml")) - with Diagram.hypergraph_equality: - assert t == d - -# u = Ty("unit") -# m = Ty("monoid") - -# def test_monoid(): -# d = Box(u.name, Ty(), m) @ Box("product", m @ m, m) -# t = compose_all(open("src/data/monoid.yaml")) -# with Diagram.hypergraph_equality: -# assert t == d diff --git a/widip/test_traverse.py b/widip/test_traverse.py new file mode 100644 index 0000000..b664487 --- /dev/null +++ b/widip/test_traverse.py @@ -0,0 +1,46 @@ +import pytest +from widip.traverse import get_base, get_fiber, vertical_map, cartesian_lift + +@pytest.mark.parametrize("data, base", [ + (1, "A"), + ({"x": 1}, "ENV"), + ([1, 2, 3], 0), +]) +def test_get_base(data, base): + obj = (data, base) + assert get_base(obj) == base + assert get_fiber(obj) == data + +@pytest.mark.parametrize("data, base", [ + (1, "A"), + ({"x": 1}, "ENV"), + ([1, 2, 3], 0), +]) +def test_get_fiber(data, base): + obj = (data, base) + assert get_fiber(obj) == data + assert get_base(obj) == base + +@pytest.mark.parametrize("start_data, func, expected_data", [ + (1, lambda x: x + 1, 2), + (10, lambda x: x * 2, 20), + ("hello", lambda x: x.upper(), "HELLO"), +]) +def test_vertical_map(start_data, func, expected_data): + base = "BASE" + obj = (start_data, base) + new_obj = vertical_map(obj, func) + assert get_fiber(new_obj) == expected_data + assert get_base(new_obj) == base + assert get_base(new_obj) == base + +@pytest.mark.parametrize("start_data, new_base, lift_fn, expected_data", [ + (10, "B", lambda d, b: d + len(b), 11), + ({"k": "v"}, "PROD", lambda d, b: {**d, "env": b}, {"k": "v", "env": "PROD"}), +]) +def test_cartesian_lift(start_data, new_base, lift_fn, expected_data): + base = "A" + obj = (start_data, base) + new_obj = cartesian_lift(obj, new_base, lift_fn) + assert get_base(new_obj) == new_base + assert get_fiber(new_obj) == expected_data diff --git a/widip/test_widish.py b/widip/test_widish.py new file mode 100644 index 0000000..ff38ac5 --- /dev/null +++ b/widip/test_widish.py @@ -0,0 +1,56 @@ +import pytest +import inspect +from discopy import closed +from unittest.mock import patch, AsyncMock +from .compiler import Exec +from .widish import SHELL_RUNNER, Process +from .thunk import unwrap, force_execution + +@pytest.mark.asyncio +async def test_exec_runner(): + # Test execution logic. + # We want to verify that running the process calls run_command with appropriate args. + + # Exec(dom, cod) + # dom = "input" + # cod = "output" + dom = closed.Ty("input") + cod = closed.Ty("output") + exec_box = Exec(dom, cod) + + # SHELL_RUNNER converts Exec to Process + process = SHELL_RUNNER(exec_box) + + # The process should: + # 1. Start with inputs corresponding to dom. + # 2. Add Constant (Gamma) -> "bin/widish" + # 3. Call Eval("bin/widish", inputs) + # Eval should trigger _deferred_exec_subprocess (or similar logic for Eval). + + # We need to mock run_command in widish.py + with patch("widip.widish.Process.run_command", new_callable=AsyncMock) as mock_run: + mock_run.return_value = "executed" + + # Run the process. + result = process("some_input") + + # result is a lazy Task (partial). Execute it. + final_result = await force_execution(result) + + # result is a tuple because discopy returns tuples + assert final_result == ("executed",) + + # Verify call arguments + # args passed to run_command: name, cmd_args, stdin + mock_run.assert_called_once() + call_args = mock_run.call_args + + # name should be resolved to string "bin/widish" + assert call_args[0][0] == "bin/widish" + + # Exec logic passes input as argument to program (Gamma) + # So cmd_args should contain "some_input" + assert call_args[0][1] == ("some_input",) + + # stdin should be empty + assert call_args[0][2] == () diff --git a/widip/thunk.py b/widip/thunk.py new file mode 100644 index 0000000..042014e --- /dev/null +++ b/widip/thunk.py @@ -0,0 +1,38 @@ +import asyncio +import inspect + +def is_awaitable(x): + return inspect.isawaitable(x) + +async def unwrap(x): + """If x is awaitable, await it. Otherwise return x.""" + if is_awaitable(x): + return await x + return x + +async def force_execution(val): + """Recursively executes callables (Tasks) and unwraps iterables.""" + if is_awaitable(val): + val = await val + return await force_execution(val) + + if callable(val): + # Assume it's a nullary task (args captured) or checks internally + res = val() + if is_awaitable(res): + res = await res + return await force_execution(res) + + if isinstance(val, (tuple, list)): + # Execute in parallel + results = await asyncio.gather(*(force_execution(x) for x in val)) + return type(val)(results) + + return val + +def flatten(container): + for i in container: + if isinstance(i, (list, tuple)): + yield from flatten(i) + else: + yield i diff --git a/widip/traverse.py b/widip/traverse.py new file mode 100644 index 0000000..5afcb1c --- /dev/null +++ b/widip/traverse.py @@ -0,0 +1,21 @@ +from typing import Any, Callable + +type T = Any +type B = Any +type FoliatedObject[T, B] = tuple[T, B] + +def get_base[T, B](obj: FoliatedObject[T, B]) -> B: + """Maps an object to its base index.""" + return obj[1] + +def get_fiber[T, B](obj: FoliatedObject[T, B]) -> T: + """Returns the fiber component of the object.""" + return obj[0] + +def vertical_map[T, B](obj: FoliatedObject[T, B], f: Callable[[T], T]) -> FoliatedObject[T, B]: + """Transformation where P(f(obj)) == P(obj).""" + return (f(obj[0]), obj[1]) + +def cartesian_lift[T, B](obj: FoliatedObject[T, B], new_index: B, lift_fn: Callable[[T, B], T]) -> FoliatedObject[T, B]: + """Transformation that moves the object from one fiber to another.""" + return (lift_fn(obj[0], new_index), new_index) diff --git a/widip/watch.py b/widip/watch.py index c46ffd2..e88b5b2 100644 --- a/widip/watch.py +++ b/widip/watch.py @@ -1,80 +1,30 @@ -from pathlib import Path +import asyncio import sys -from watchdog.events import FileSystemEventHandler -from watchdog.observers import Observer -from yaml import YAMLError -from discopy.closed import Id, Ty, Box -from discopy.utils import tuplify, untuplify +from .files import reload_diagram -from .loader import repl_read -from .files import diagram_draw, file_diagram -from .widish import SHELL_RUNNER, compile_shell_program +async def handle_changes(): + from watchfiles import awatch + async for changes in awatch('.', recursive=True): + for change_type, path_str in changes: + if path_str.endswith(".yaml"): + reload_diagram(path_str) -# TODO watch functor ?? +async def run_with_watcher(coro): + # Start watcher + watcher_task = None + if __debug__: + if sys.stdin.isatty(): + print(f"watching for changes in current path", file=sys.stderr) + watcher_task = asyncio.create_task(handle_changes()) -class ShellHandler(FileSystemEventHandler): - """Reload the shell on change.""" - def on_modified(self, event): - if event.src_path.endswith(".yaml"): - print(f"reloading {event.src_path}") - try: - fd = file_diagram(str(event.src_path)) - diagram_draw(Path(event.src_path), fd) - diagram_draw(Path(event.src_path+".2"), fd) - except YAMLError as e: - print(e) - -def watch_main(): - """the process manager for the reader and """ - # TODO watch this path to reload changed files, - # returning an IO as always and maintaining the contract. - print(f"watching for changes in current path") - observer = Observer() - shell_handler = ShellHandler() - observer.schedule(shell_handler, ".", recursive=True) - observer.start() - return observer - -def shell_main(file_name): try: - while True: - observer = watch_main() + await coro + finally: + if watcher_task: + watcher_task.cancel() try: - prompt = f"--- !{file_name}\n" - source = input(prompt) - source_d = repl_read(source) - # source_d.draw( - # textpad=(0.3, 0.1), - # fontsize=12, - # fontsize_types=8) - path = Path(file_name) - diagram_draw(path, source_d) - # source_d = compile_shell_program(source_d) - # diagram_draw(Path(file_name+".2"), source_d) - # source_d = Spider(0, len(source_d.dom), Ty("io")) \ - # >> source_d \ - # >> Spider(len(source_d.cod), 1, Ty("io")) - # diagram_draw(path, source_d) - result_ev = SHELL_RUNNER(source_d)() - print(result_ev) - except KeyboardInterrupt: - print() - except YAMLError as e: - print(e) - finally: - observer.stop() - except EOFError: - print("⌁") - exit(0) - -def widish_main(file_name, *shell_program_args: str): - fd = file_diagram(file_name) - path = Path(file_name) - diagram_draw(path, fd) - constants = tuple(x.name for x in fd.dom) - runner = SHELL_RUNNER(fd)(*constants) - # TODO pass stdin - run_res = runner and runner("") - print(*(tuple(x.rstrip() for x in tuplify(untuplify(run_res)) if x)), sep="\n") + await watcher_task + except asyncio.CancelledError: + pass diff --git a/widip/widish.py b/widip/widish.py index 997a6b4..5452b94 100644 --- a/widip/widish.py +++ b/widip/widish.py @@ -1,79 +1,254 @@ +import asyncio from functools import partial -from itertools import batched -from subprocess import CalledProcessError, run - -from discopy.closed import Category, Functor, Ty, Box, Eval from discopy.utils import tuplify, untuplify -from discopy import python +from discopy import closed, python, utils + +from .computer import * +from .thunk import unwrap, is_awaitable + +async def _bridge_pipe(f, g, *args): + # f and g are Processes. f(*args) returns Awaitable. + res = await unwrap(f(*args)) + # g expects inputs. res is output of f. + # g(*res) returns Awaitable. + return await unwrap(g(*utils.tuplify(res))) + +async def _tensor_inside(f, g, n, *args): + args1, args2 = args[:n], args[n:] + res1 = await unwrap(f(*args1)) + res2 = await unwrap(g(*args2)) + return tuplify(res1) + tuplify(res2) + +async def _eval_func(f, *x): + return await unwrap(f(*x)) +def _lazy(func, ar): + """Returns a function that returns a partial application of func.""" + async def wrapper(*args): + # Return a partial (Runner) + # This wrapper is an async function, so calling it returns a Coroutine (Awaitable). + # This Coroutine resolves to the partial. + return partial(func, ar, *args) + return wrapper -io_ty = Ty("io") +class Process(python.Function): + def __init__(self, inside, dom, cod): + super().__init__(inside, dom, cod) + self.type_checking = False -def run_native_subprocess(ar, *b): - def run_native_subprocess_constant(*params): + def then(self, other): + return Process( + partial(_bridge_pipe, self, other), + self.dom, + other.cod, + ) + + def tensor(self, other): + return Process( + partial(_tensor_inside, self, other, len(self.dom)), + self.dom + other.dom, + self.cod + other.cod + ) + + @classmethod + def eval(cls, base, exponent, left=True): + return Process( + _eval_func, + (exponent << base) @ base, + exponent + ) + + @staticmethod + def split_args(ar, *args): + n = len(ar.dom) + return args[:n], args[n:] + + @classmethod + async def run_constant(cls, ar, *args): + b, params = cls.split_args(ar, *args) if not params: - return "" if ar.dom == Ty() else ar.dom.name + if ar.dom == closed.Ty(): + return () + return ar.dom.name return untuplify(params) - def run_native_subprocess_map(*params): - # TODO cat then copy to two - # but formal is to pass through - mapped = [] - start = 0 - for (dk, k), (dv, v) in batched(zip(ar.dom, b), 2): - # note that the key cod and value dom might be different - b0 = k(*tuplify(params)) - res = untuplify(v(*tuplify(b0))) - mapped.append(untuplify(res)) - - return untuplify(tuple(mapped)) - def run_native_subprocess_seq(*params): - b0 = b[0](*untuplify(params)) - res = untuplify(b[1](*tuplify(b0))) - return res - def run_native_subprocess_inside(*params): - try: - io_result = run( - b, - check=True, text=True, capture_output=True, - input="\n".join(params) if params else None, - ) - res = io_result.stdout.rstrip("\n") - return res - except CalledProcessError as e: - return e.stderr - if ar.name == "⌜−⌝": - return run_native_subprocess_constant - if ar.name == "(||)": - return run_native_subprocess_map - if ar.name == "(;)": - return run_native_subprocess_seq - if ar.name == "g": - res = run_native_subprocess_inside(*b) + + @classmethod + async def run_map(cls, ar, *args): + b, params = cls.split_args(ar, *args) + + async def run_branch(kv): + # kv is Awaitable (resolving to Task). + task = await unwrap(kv) + # task is Task (partial). Run it. + res = await unwrap(task(*tuplify(params))) + return tuplify(res) # Ensure tuple for sum + + results = await asyncio.gather(*(run_branch(kv) for kv in b)) + return sum(results, ()) + + @classmethod + async def run_seq(cls, ar, *args): + b, params = cls.split_args(ar, *args) + if not b: + return params + + # Resolve first task + task = await unwrap(b[0]) + # Run it + res = await unwrap(task(*tuplify(params))) + + for kv in b[1:]: + task = await unwrap(kv) + res = await unwrap(task(*tuplify(res))) + return res - if ar.name == "G": - return run_native_subprocess_inside - -SHELL_RUNNER = Functor( - lambda ob: str, - lambda ar: partial(run_native_subprocess, ar), - cod=Category(python.Ty, python.Function)) - - -SHELL_COMPILER = Functor( - # lambda ob: Ty() if ob == Ty("io") else ob, - lambda ob: ob, - lambda ar: { - # "ls": ar.curry().uncurry() - }.get(ar.name, ar),) - # TODO remove .inside[0] workaround - # lambda ar: ar) - - -def compile_shell_program(diagram): - """ - close input parameters (constants) - drop outputs matching input parameters - all boxes are io->[io]""" - # TODO compile sequences and parallels to evals - diagram = SHELL_COMPILER(diagram) - return diagram + + @staticmethod + def run_swap(ar, *args): + n_left = len(ar.left) + n_right = len(ar.right) + left_args = args[:n_left] + right_args = args[n_left : n_left + n_right] + return untuplify(right_args + left_args) + + @classmethod + def run_cast(cls, ar, *args): + b, params = cls.split_args(ar, *args) + func = b[0] + return func + + @classmethod + def run_copy(cls, ar, *args): + b, params = cls.split_args(ar, *args) + return b * ar.n + + @staticmethod + def run_discard(ar, *args): + return () + + @staticmethod + async def run_command(name, args, stdin): + # this enables non-executable + # YAML files to be run as commands + if name.endswith(".yaml"): + args = (name, ) + args + name = "bin/widish" + + process = await asyncio.create_subprocess_exec( + name, *args, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + input_data = "\n".join(stdin).encode() if stdin else None + stdout, stderr = await process.communicate(input=input_data) + if stderr: + import sys + print(stderr.decode(), file=sys.stderr) + return stdout.decode().rstrip("\n") + + @classmethod + async def deferred_exec(cls, ar, *args): + b, params = cls.split_args(ar, *args) + + async def resolve(x): + if is_awaitable(x): + x = await unwrap(x) + # If it's a runner (partial) for a value (Program/Constant), run it to get the value + if callable(x): + return await unwrap(x()) + return x + + # Resolve inputs (Awaitables -> Runners -> Values) + b = tuple([await resolve(x) for x in b]) + params = tuple([await resolve(x) for x in params]) + + # Flatten params + flat_params = [] + for p in params: + if isinstance(p, tuple): + flat_params.extend(p) + else: + flat_params.append(p) + params = tuple(flat_params) + + # Flatten b + flat_b = [] + for x in b: + if isinstance(x, tuple): + flat_b.extend(x) + else: + flat_b.append(x) + b = tuple(flat_b) + + name, cmd_args = ( + (ar.name, b) if ar.name + else (b[0], b[1:]) if b + else (None, ()) + ) + + # Generic brace expansion for any command + # e.g. {a, b} -> ("a", "b") + if len(cmd_args) == 1 and cmd_args[0].startswith("{") and cmd_args[0].endswith("}"): + # Parse simple {a, b} syntax + content = cmd_args[0][1:-1] + split_args = [s.strip() for s in content.split(",")] + cmd_args = tuple(split_args) + + result = await cls.run_command(name, cmd_args, params) + return result + + @staticmethod + def run_program(ar, *args): + return ar.name + + @staticmethod + def run_constant_gamma(ar, *args): + return "bin/widish" + +Widish = closed.Category(python.Ty, Process) + +def shell_runner_ar(ar): + if isinstance(ar, Data): + t = _lazy(Process.run_constant, ar) + elif isinstance(ar, Concurrent): + t = _lazy(Process.run_map, ar) + elif isinstance(ar, Pair): + t = _lazy(Process.run_seq, ar) + elif isinstance(ar, Sequential): + t = _lazy(Process.run_seq, ar) + elif isinstance(ar, Swap): + t = partial(Process.run_swap, ar) + elif isinstance(ar, Cast): + t = _lazy(Process.run_cast, ar) + elif isinstance(ar, Copy): + t = partial(Process.run_copy, ar) + elif isinstance(ar, Discard): + t = partial(Process.run_discard, ar) + elif isinstance(ar, Exec): + gamma = Constant() + diagram = gamma @ closed.Id(ar.dom) >> Eval(ar.dom, ar.cod) + return SHELL_RUNNER(diagram) + elif isinstance(ar, Constant): + t = _lazy(Process.run_constant_gamma, ar) + elif isinstance(ar, Program): + t = _lazy(Process.run_program, ar) + elif isinstance(ar, Eval): + t = _lazy(Process.deferred_exec, ar) + else: + t = _lazy(Process.deferred_exec, ar) + + dom = SHELL_RUNNER(ar.dom) + cod = SHELL_RUNNER(ar.cod) + return Process(t, dom, cod) + +class WidishFunctor(closed.Functor): + def __init__(self): + super().__init__( + lambda ob: object, + shell_runner_ar, + dom=Computation, + cod=Widish + ) + +SHELL_RUNNER = WidishFunctor() diff --git a/widip/yaml.py b/widip/yaml.py new file mode 100644 index 0000000..42b5143 --- /dev/null +++ b/widip/yaml.py @@ -0,0 +1,75 @@ +from discopy import closed, monoidal + + +# TODO node class is unnecessary +class Node(closed.Box): + def __init__(self, name, dom, cod): + super().__init__(name, dom, cod) + +class Scalar(closed.Box): + def __init__(self, tag, value): + dom = closed.Ty(value) if value else closed.Ty() + cod = closed.Ty(tag) >> closed.Ty(tag) if tag else closed.Ty() >> closed.Ty(value) + super().__init__("Scalar", dom, cod) + + @property + def tag(self): + if not self.cod or not self.cod[0].is_exp: return "" + u = self.cod[0].inside[0] + return u.base.name if u.base == u.exponent else "" + + @property + def value(self): + if self.dom: return self.dom[0].name + if not self.cod or not self.cod[0].is_exp: return "" + u = self.cod[0].inside[0] + return u.base.name if not self.tag else "" + +class Sequence(monoidal.Bubble, closed.Box): + def __init__(self, inside, dom=None, cod=None, n=None): + if dom is None: + dom = inside.dom + + if cod is None: + # If n=2 is explicitly requested, use Pair logic (K -> V) + # Otherwise use Tuple logic (all inputs -> all outputs) + if n == 2: + mid = len(inside.cod) // 2 + exps, _ = get_exps_bases(inside.cod[:mid]) + _, bases = get_exps_bases(inside.cod[mid:]) + cod = exps >> bases + else: + exps, bases = get_exps_bases(inside.cod) + cod = exps >> bases + + self.n = n if n is not None else len(inside.cod) + super().__init__(inside, dom=dom, cod=cod) + # Change method to bypass Functor's default bubble handling + self.method = "sequence_bubble" + +class Mapping(monoidal.Bubble, closed.Box): + def __init__(self, inside, dom=None, cod=None): + if dom is None: + dom = inside.dom + if cod is None: + exps, bases = get_exps_bases(inside.cod) + cod = bases << exps + super().__init__(inside, dom=dom, cod=cod) + self.method = "mapping_bubble" + +class Anchor(closed.Box): + def __init__(self, name, dom, cod): + super().__init__(name, dom, cod) + +class Alias(closed.Box): + def __init__(self, name, dom, cod): + super().__init__(name, dom, cod) + +Yaml = closed.Category() + +# TODO remove closed structure from yaml and loader +# and move it to computer +def get_exps_bases(cod): + exps = closed.Ty().tensor(*[x.inside[0].exponent for x in cod]) + bases = closed.Ty().tensor(*[x.inside[0].base for x in cod]) + return exps, bases