├── docs ├── .nojekyll ├── index.html └── style.css ├── test ├── __init__.py ├── serial │ ├── test_paths.py │ ├── test_dictlike.py │ ├── test_remote_io.py │ ├── test_namedtuple.py │ └── test_numpy.py ├── workflows │ ├── operators.py │ ├── capture_output.py │ ├── dict_likes.py │ ├── unpack.py │ ├── nesting.py │ ├── class_methods.py │ ├── gather.py │ ├── lift.py │ ├── __init__.py │ ├── workflow_factory.py │ ├── recursion.py │ ├── conditionals.py │ ├── patterns.py │ └── setters.py ├── lib │ ├── test_thread_pool.py │ ├── test_queue.py │ ├── test_coroutine.py │ ├── test_utility.py │ ├── test_decorator.py │ └── test_streams.py ├── test_exceptions.py ├── backends │ ├── backend_factory.py │ └── __init__.py ├── test_timer.py ├── test_results.py ├── test_scheduled_method.py ├── test_deep_map.py ├── test_global_prov.py ├── test_globals.py ├── test_matrix.py ├── xenon │ └── test_run_xenon.py ├── test_invert_graph.py ├── test_broker.py ├── test_sqlite.py ├── test_hybrid.py ├── conftest.py └── test_merge_workflow.py ├── noodles ├── run │ ├── __init__.py │ ├── remote │ │ ├── __init__.py │ │ ├── io.py │ │ └── worker_config.py │ ├── single │ │ ├── __init__.py │ │ ├── vanilla.py │ │ └── sqlite3.py │ ├── threading │ │ ├── __init__.py │ │ ├── vanilla.py │ │ └── sqlite3.py │ ├── xenon │ │ ├── __init__.py │ │ ├── runner.py │ │ └── xenon.py │ ├── worker.py │ ├── messages.py │ ├── runners.py │ ├── logging.py │ ├── job_keeper.py │ └── hybrid.py ├── display │ ├── curses │ │ └── __init__.py │ ├── __init__.py │ ├── pretty.py │ ├── pretty_term.py │ └── dumb_term.py ├── prov │ ├── __init__.py │ ├── key.py │ └── workflow.py ├── draw_workflow │ ├── __init__.py │ └── draw_workflow.py ├── patterns │ ├── __init__.py │ ├── control_flow_statements.py │ ├── find_first.py │ └── functional_patterns.py ├── config.py ├── serial │ ├── dataclass.py │ ├── path.py │ ├── as_dict.py │ ├── __init__.py │ ├── reasonable.py │ ├── pickle.py │ └── namedtuple.py ├── lib │ ├── coroutine.py │ ├── decorator.py │ ├── connection.py │ ├── thread_pool.py │ └── queue.py ├── workflow │ ├── __init__.py │ ├── mutations.py │ └── graphs.py ├── interface │ ├── __init__.py │ └── maybe.py └── __init__.py ├── requirements.txt ├── doc └── source │ ├── errors.ipynb │ ├── control-fox.svg │ ├── errors-stat.svg │ ├── poetry-lift.svg │ ├── first_steps.ipynb │ ├── poetry-sizes.svg │ ├── control-factors.svg │ ├── prime_numbers.ipynb │ ├── serialisation.ipynb │ ├── control-recursion.svg │ ├── control_your_flow.ipynb │ ├── errors-arithmetic.svg │ ├── poetry_tutorial.ipynb │ ├── sha256-performance.svg │ ├── control-factorial-one.svg │ ├── control-quick-brown-fox.svg │ ├── control-tail-recursion.svg │ ├── first_steps-workflow-a.svg │ ├── first_steps-workflow-b.svg │ ├── first_steps-workflow-c.svg │ ├── first_steps-workflow-d.svg │ ├── control-tail-recursive-factorial.svg │ ├── _static │ └── images │ │ ├── dag1.png │ │ ├── dag2.png │ │ ├── matthew.png │ │ ├── nlesc.png │ │ ├── poetry.png │ │ ├── pythagoras.png │ │ └── wf1-series.png │ ├── implementation.rst │ ├── boil_source.rst │ ├── tutorials.rst │ ├── development.rst │ ├── cooking.rst │ └── index.rst ├── readthedocs.yml ├── examples ├── callgraph.png ├── README.md ├── boil │ ├── test │ │ ├── main.cc │ │ ├── test_read.cc │ │ ├── test_sanity.cc │ │ ├── test.hh │ │ └── test.cc │ ├── src │ │ ├── common.cc │ │ ├── read.hh │ │ ├── iterate.cc │ │ ├── types.hh │ │ ├── julia.cc │ │ ├── mandel.cc │ │ ├── common.hh │ │ └── render.cc │ ├── boil.ini │ ├── README.rst │ └── main │ │ └── main.cc ├── draw │ ├── minimal.py │ ├── run.py │ └── pythagoras.py ├── maybe.py ├── million.py ├── example1.py ├── soba │ ├── README.rst │ └── subcommands.json ├── noodles.ini ├── example2.py ├── class-decorator.py ├── static_sum.py └── das5.py ├── scripts ├── header.html ├── annotate-code-blocks.lua ├── list.lua ├── tangle ├── weave └── tangle.lua ├── notebooks ├── .gitignore ├── first_steps-workflow-a.svg ├── hard_and_easy.ipynb ├── first_steps-workflow-b.svg ├── control-factorial-one.svg ├── control-tail-recursive-factorial.svg ├── first_steps-workflow-c.svg ├── errors-arithmetic.svg ├── inspecting_db.ipynb └── first_steps.ipynb ├── CHANGELOG.md ├── .travis.yml ├── .gitignore ├── CONTRIBUTING.md ├── .coveragerc ├── pyproject.toml ├── .editorconfig ├── setup.py ├── .zenodo.json ├── README.md └── CODE_OF_CONDUCT.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noodles/run/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noodles/run/remote/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noodles/run/single/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noodles/run/threading/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noodles/display/curses/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | nbsphinx 2 | ipykernel 3 | -------------------------------------------------------------------------------- /doc/source/errors.ipynb: -------------------------------------------------------------------------------- 1 | ../../notebooks/errors.ipynb -------------------------------------------------------------------------------- /doc/source/control-fox.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/control-fox.svg -------------------------------------------------------------------------------- /doc/source/errors-stat.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/errors-stat.svg -------------------------------------------------------------------------------- /doc/source/poetry-lift.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/poetry-lift.svg -------------------------------------------------------------------------------- /doc/source/first_steps.ipynb: -------------------------------------------------------------------------------- 1 | ../../notebooks/first_steps.ipynb -------------------------------------------------------------------------------- /doc/source/poetry-sizes.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/poetry-sizes.svg -------------------------------------------------------------------------------- /doc/source/control-factors.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/control-factors.svg -------------------------------------------------------------------------------- /doc/source/prime_numbers.ipynb: -------------------------------------------------------------------------------- 1 | ../../notebooks/prime_numbers.ipynb -------------------------------------------------------------------------------- /doc/source/serialisation.ipynb: -------------------------------------------------------------------------------- 1 | ../../notebooks/serialisation.ipynb -------------------------------------------------------------------------------- /doc/source/control-recursion.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/control-recursion.svg -------------------------------------------------------------------------------- /doc/source/control_your_flow.ipynb: -------------------------------------------------------------------------------- 1 | ../../notebooks/control_your_flow.ipynb -------------------------------------------------------------------------------- /doc/source/errors-arithmetic.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/errors-arithmetic.svg -------------------------------------------------------------------------------- /doc/source/poetry_tutorial.ipynb: -------------------------------------------------------------------------------- 1 | ../../notebooks/poetry_tutorial.ipynb -------------------------------------------------------------------------------- /doc/source/sha256-performance.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/sha256-performance.svg -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | python: 2 | version: 3.5 3 | pip_install: true 4 | -------------------------------------------------------------------------------- /doc/source/control-factorial-one.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/control-factorial-one.svg -------------------------------------------------------------------------------- /doc/source/control-quick-brown-fox.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/control-quick-brown-fox.svg -------------------------------------------------------------------------------- /doc/source/control-tail-recursion.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/control-tail-recursion.svg -------------------------------------------------------------------------------- /doc/source/first_steps-workflow-a.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/first_steps-workflow-a.svg -------------------------------------------------------------------------------- /doc/source/first_steps-workflow-b.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/first_steps-workflow-b.svg -------------------------------------------------------------------------------- /doc/source/first_steps-workflow-c.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/first_steps-workflow-c.svg -------------------------------------------------------------------------------- /doc/source/first_steps-workflow-d.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/first_steps-workflow-d.svg -------------------------------------------------------------------------------- /examples/callgraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NLeSC/noodles/HEAD/examples/callgraph.png -------------------------------------------------------------------------------- /doc/source/control-tail-recursive-factorial.svg: -------------------------------------------------------------------------------- 1 | ../../notebooks/control-tail-recursive-factorial.svg -------------------------------------------------------------------------------- /scripts/header.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /doc/source/_static/images/dag1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NLeSC/noodles/HEAD/doc/source/_static/images/dag1.png -------------------------------------------------------------------------------- /doc/source/_static/images/dag2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NLeSC/noodles/HEAD/doc/source/_static/images/dag2.png -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Howto run 2 | 3 | From this folder say: 4 | > PYTHONPATH=".." python3 example.py 5 | 6 | -------------------------------------------------------------------------------- /doc/source/_static/images/matthew.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NLeSC/noodles/HEAD/doc/source/_static/images/matthew.png -------------------------------------------------------------------------------- /doc/source/_static/images/nlesc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NLeSC/noodles/HEAD/doc/source/_static/images/nlesc.png -------------------------------------------------------------------------------- /doc/source/_static/images/poetry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NLeSC/noodles/HEAD/doc/source/_static/images/poetry.png -------------------------------------------------------------------------------- /examples/boil/test/main.cc: -------------------------------------------------------------------------------- 1 | #include "test.hh" 2 | 3 | int main(int argc, char **argv) { 4 | Test::all(false); 5 | } 6 | -------------------------------------------------------------------------------- /noodles/prov/__init__.py: -------------------------------------------------------------------------------- 1 | from .sqlite import JobDB 2 | from .key import (prov_key) 3 | 4 | __all__ = ['prov_key', 'JobDB'] 5 | -------------------------------------------------------------------------------- /doc/source/_static/images/pythagoras.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NLeSC/noodles/HEAD/doc/source/_static/images/pythagoras.png -------------------------------------------------------------------------------- /doc/source/_static/images/wf1-series.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NLeSC/noodles/HEAD/doc/source/_static/images/wf1-series.png -------------------------------------------------------------------------------- /noodles/draw_workflow/__init__.py: -------------------------------------------------------------------------------- 1 | from .draw_workflow import draw_workflow, graph 2 | 3 | __all__ = ['draw_workflow', 'graph'] 4 | -------------------------------------------------------------------------------- /notebooks/.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | tutorial.h5 3 | tutorial.lock 4 | mprofile_*.dat 5 | test-recursion.py 6 | test-tail-recursion.py 7 | 8 | -------------------------------------------------------------------------------- /doc/source/implementation.rst: -------------------------------------------------------------------------------- 1 | Implementation 2 | ============== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | development 8 | scheduler 9 | -------------------------------------------------------------------------------- /doc/source/boil_source.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: python 2 | :linenothreshold: 5 3 | 4 | Boil: the source 5 | ~~~~~~~~~~~~~~~~ 6 | 7 | .. literalinclude:: ../../examples/boil/boil 8 | :linenos: 9 | -------------------------------------------------------------------------------- /noodles/run/xenon/__init__.py: -------------------------------------------------------------------------------- 1 | from .runner import (run_xenon, run_xenon_simple) 2 | from .xenon import (Machine, XenonJobConfig) 3 | 4 | __all__ = ['run_xenon', 'run_xenon_simple', 'Machine', 'XenonJobConfig'] 5 | -------------------------------------------------------------------------------- /noodles/display/__init__.py: -------------------------------------------------------------------------------- 1 | from .simple import Display as SimpleDisplay 2 | from .dumb_term import DumbDisplay 3 | from .simple_nc import Display as NCDisplay 4 | 5 | __all__ = ['SimpleDisplay', 'DumbDisplay', 'NCDisplay'] 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Removed 4 | 5 | * `noodles.file` unused module 6 | 7 | 8 | ## Changed 9 | 10 | * Added `SerPath` class to serial namespace 11 | 12 | 13 | ## Fixed 14 | * `Fail` class serialization 15 | -------------------------------------------------------------------------------- /examples/boil/src/common.cc: -------------------------------------------------------------------------------- 1 | #include "types.hh" 2 | 3 | function f(complex c) { 4 | return [c] (complex z) { 5 | return z*z + c; 6 | }; 7 | } 8 | 9 | bool pred(complex z) { 10 | return std::norm(z) < 4.0; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /examples/boil/test/test_read.cc: -------------------------------------------------------------------------------- 1 | #include "test.hh" 2 | #include "../src/read.hh" 3 | 4 | Test test_read("src/read.hh", [] () { 5 | assert(read("15") == 15); 6 | assert(read("3.1415") == 3.1415); 7 | return true; 8 | }); 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | sudo: true 4 | python: 5 | - "3.5" 6 | - "3.6" 7 | - "3.7" 8 | install: 9 | - pip install tox-travis 10 | script: 11 | - tox 12 | branches: 13 | only: 14 | - master 15 | - develop 16 | -------------------------------------------------------------------------------- /examples/boil/src/read.hh: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | template 7 | T read(std::string s) { 8 | T v; 9 | std::istringstream iss(s); 10 | iss >> v; 11 | return v; 12 | } 13 | 14 | -------------------------------------------------------------------------------- /doc/source/tutorials.rst: -------------------------------------------------------------------------------- 1 | Tutorials 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | first_steps 8 | poetry_tutorial 9 | boil_tutorial 10 | prime_numbers 11 | errors 12 | serialisation 13 | control_your_flow 14 | -------------------------------------------------------------------------------- /examples/draw/minimal.py: -------------------------------------------------------------------------------- 1 | from noodles.tutorial import (add, sub, mul) 2 | from noodles.draw_workflow import draw_workflow 3 | 4 | 5 | u = add(5, 4) 6 | v = sub(u, 3) 7 | w = sub(u, 2) 8 | x = mul(v, w) 9 | 10 | draw_workflow("callgraph-a.pdf", x._workflow) 11 | -------------------------------------------------------------------------------- /examples/boil/src/iterate.cc: -------------------------------------------------------------------------------- 1 | #include "types.hh" 2 | 3 | int iterate(function f, predicate pred, complex z, unsigned maxit) { 4 | unsigned i = 0; 5 | 6 | while (pred(z) && i < maxit) { 7 | z = f(z); 8 | ++i; 9 | } 10 | 11 | return i; 12 | } 13 | 14 | -------------------------------------------------------------------------------- /test/serial/test_paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from noodles import (serial) 3 | 4 | 5 | def test_path(): 6 | a = Path("/usr/bin/python") 7 | registry = serial.base() 8 | 9 | encoded = registry.deep_encode(a) 10 | b = registry.deep_decode(encoded) 11 | 12 | assert a == b 13 | -------------------------------------------------------------------------------- /examples/boil/src/types.hh: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | using complex = std::complex; 7 | using function = std::function; 8 | using predicate = std::function; 9 | using unit_map = std::function; 10 | 11 | -------------------------------------------------------------------------------- /noodles/patterns/__init__.py: -------------------------------------------------------------------------------- 1 | from .control_flow_statements import ( 2 | conditional) 3 | from .find_first import (find_first) 4 | from .functional_patterns import (all, any, filter, fold, map, zip_with) 5 | 6 | __all__ = [ 7 | 'all', 'any', 'filter', 'fold', 'find_first', 'conditional', 'map', 8 | 'zip_with'] 9 | -------------------------------------------------------------------------------- /noodles/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | 3 | config = { 4 | # call_by_value: keep this True unless you know what you're doing. 5 | 'call_by_value': True, 6 | } 7 | 8 | 9 | def read_config(filename: str): 10 | config = configparser.ConfigParser() 11 | config.read(filename) 12 | return config 13 | -------------------------------------------------------------------------------- /test/workflows/operators.py: -------------------------------------------------------------------------------- 1 | import noodles 2 | # import numpy as np 3 | 4 | from .workflow_factory import workflow_factory 5 | 6 | 7 | @noodles.schedule 8 | def promise(x): 9 | return x 10 | 11 | 12 | @workflow_factory(result=42, python_version=(3, 7)) 13 | def answer(): 14 | return promise(6) * promise(7) 15 | -------------------------------------------------------------------------------- /test/workflows/capture_output.py: -------------------------------------------------------------------------------- 1 | from .workflow_factory import workflow_factory 2 | import noodles 3 | 4 | 5 | @noodles.schedule 6 | def writes_to_stdout(): 7 | print("Hello Noodles!") 8 | return 42 9 | 10 | 11 | @workflow_factory( 12 | result=42, 13 | requires=['remote']) 14 | def capture_output(): 15 | return writes_to_stdout() 16 | -------------------------------------------------------------------------------- /noodles/serial/dataclass.py: -------------------------------------------------------------------------------- 1 | from .registry import Serialiser 2 | 3 | 4 | class SerDataClass(Serialiser): 5 | def __init__(self): 6 | super(SerDataClass, self).__init__('') 7 | 8 | def encode(self, obj, make_rec): 9 | return make_rec(obj.__dict__) 10 | 11 | def decode(self, cls, data): 12 | return cls(**data) 13 | -------------------------------------------------------------------------------- /examples/maybe.py: -------------------------------------------------------------------------------- 1 | from noodles import (schedule, maybe, run_single) 2 | 3 | 4 | @schedule 5 | @maybe 6 | def inv(x): 7 | return 1/x 8 | 9 | 10 | @schedule 11 | @maybe 12 | def add(**args): 13 | return sum(args.values()) 14 | 15 | 16 | if __name__ == "__main__": 17 | wf = add(a=inv(0), b=inv(0)) 18 | result = run_single(wf) 19 | print(result) 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | */__pycache__/* 2 | *.pyc 3 | tmp 4 | trash 5 | cover 6 | .coverage 7 | **/.ipynb_checkpoints 8 | */.ipynb_checkpoints/* 9 | *.idea 10 | *~ 11 | .*.swp 12 | doc/build 13 | venv 14 | cache* 15 | *.egg-info 16 | .tox 17 | .cache 18 | .coverage.* 19 | htmlcov 20 | coverage.xml 21 | 22 | # vscode 23 | .vscode 24 | 25 | # local testing 26 | local 27 | snippets 28 | -------------------------------------------------------------------------------- /noodles/serial/path.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from .registry import (Serialiser) 3 | 4 | 5 | class SerPath(Serialiser): 6 | def __init__(self): 7 | super(SerPath, self).__init__(Path) 8 | 9 | def encode(self, obj, make_rec): 10 | return make_rec(str(obj), files=[str(obj)]) 11 | 12 | def decode(self, cls, data): 13 | return Path(data) 14 | -------------------------------------------------------------------------------- /noodles/serial/as_dict.py: -------------------------------------------------------------------------------- 1 | from .registry import (Serialiser) 2 | 3 | 4 | class AsDict(Serialiser): 5 | def __init__(self, cls): 6 | super(AsDict, self).__init__(cls) 7 | 8 | def encode(self, obj, make_rec): 9 | return make_rec(obj.__dict__) 10 | 11 | def decode(self, cls, data): 12 | obj = cls.__new__(cls) 13 | obj.__dict__ = data 14 | return obj 15 | -------------------------------------------------------------------------------- /examples/draw/run.py: -------------------------------------------------------------------------------- 1 | from noodles import gather 2 | from noodles.draw_workflow import draw_workflow 3 | from noodles.tutorial import (add, mul, sub, accumulate) 4 | 5 | 6 | def test_42(): 7 | A = add(1, 1) 8 | B = sub(3, A) 9 | 10 | multiples = [mul(add(i, B), A) for i in range(6)] 11 | return accumulate(gather(*multiples)) 12 | 13 | 14 | draw_workflow("wf42.svg", test_42()._workflow) 15 | -------------------------------------------------------------------------------- /examples/million.py: -------------------------------------------------------------------------------- 1 | import noodles 2 | from noodles.run.local import run_single 3 | 4 | 5 | @noodles.schedule 6 | def large_sum(lst, acc=0): 7 | if len(lst) < 1000: 8 | return acc + sum(lst) 9 | else: 10 | return large_sum(lst[1000:], acc+sum(lst[:1000])) 11 | 12 | 13 | a = large_sum(range(1000000)) 14 | result = run_single(a) 15 | 16 | print("sum of 1 .. 1000000 is ", result) 17 | -------------------------------------------------------------------------------- /examples/boil/boil.ini: -------------------------------------------------------------------------------- 1 | [generic] 2 | objdir = obj 3 | ldflags = -lm 4 | cflags = -g -std=c++11 -O2 -fdiagnostics-color -Wpedantic -Wall 5 | cc = g++ 6 | ext = .cc 7 | 8 | [main] 9 | srcdir = main 10 | target = hello 11 | modules = ../src 12 | 13 | [test] 14 | srcdir = test 15 | target = unittests 16 | modules = ../src 17 | 18 | [clean] 19 | command = rm -rf ${generic:objdir} ${test:target} ${main:target} 20 | -------------------------------------------------------------------------------- /examples/boil/src/julia.cc: -------------------------------------------------------------------------------- 1 | #include "common.hh" 2 | #include 3 | 4 | predicate julia(complex c, int maxit) { 5 | return [c, maxit] (complex z) { 6 | return iterate(f(c), pred, z, maxit) == maxit; 7 | }; 8 | } 9 | 10 | unit_map julia_c(complex c, int maxit) { 11 | return [c, maxit] (complex z) { 12 | return sqrt(double(iterate(f(c), pred, z, maxit)) / maxit); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /test/lib/test_thread_pool.py: -------------------------------------------------------------------------------- 1 | from noodles.lib import (thread_pool, Queue, pull_map, pull_from, patch) 2 | 3 | 4 | def test_thread_pool(): 5 | @pull_map 6 | def square(x): 7 | return x*x 8 | 9 | Q = Queue() 10 | worker = Q >> thread_pool(square, square) 11 | patch(pull_from(range(10)), worker.sink) 12 | Q.close() 13 | 14 | assert sorted(list(worker.source)) == [i**2 for i in range(10)] 15 | -------------------------------------------------------------------------------- /examples/boil/src/mandel.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include "common.hh" 3 | 4 | predicate mandelbrot(int maxit) { 5 | return [maxit] (complex c) { 6 | return iterate(f(c), pred, complex(0, 0), maxit) == maxit; 7 | }; 8 | } 9 | 10 | unit_map mandelbrot_c(int maxit) { 11 | return [maxit] (complex c) { 12 | return sqrt(double(iterate(f(c), pred, complex(0, 0), maxit)) / maxit); 13 | }; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /test/workflows/dict_likes.py: -------------------------------------------------------------------------------- 1 | from .workflow_factory import workflow_factory 2 | import noodles 3 | 4 | 5 | class A(dict): 6 | pass 7 | 8 | 9 | @noodles.schedule 10 | def f(a): 11 | a['value'] = 5 12 | return a 13 | 14 | 15 | @workflow_factory( 16 | assertions=[ 17 | lambda r: isinstance(r, A), 18 | lambda r: r['value'] == 5]) 19 | def test_dict_like(): 20 | a = A() 21 | return f(a) 22 | -------------------------------------------------------------------------------- /examples/boil/test/test_sanity.cc: -------------------------------------------------------------------------------- 1 | #include "test.hh" 2 | #include "../src/common.hh" 3 | 4 | Test test_mandel("src/mandel.cc", [] () { 5 | assert(mandelbrot(256)(complex(0,0))); 6 | assert(!mandelbrot(256)(complex(2,2))); 7 | return true; 8 | }); 9 | 10 | Test test_julia("src/julia.cc", [] () { 11 | assert(julia(complex(0,0), 256)(complex(0,0))); 12 | assert(!julia(complex(0,0), 256)(complex(2,2))); 13 | return true; 14 | }); 15 | -------------------------------------------------------------------------------- /test/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | from noodles import (schedule, run_single) 3 | 4 | 5 | class MyException(Exception): 6 | def __init__(self, msg): 7 | super(MyException, self).__init__(msg) 8 | 9 | 10 | @schedule 11 | def raises_my_exception(): 12 | raise MyException("Error!") 13 | 14 | 15 | def test_exception_00(): 16 | with raises(MyException): 17 | wf = raises_my_exception() 18 | run_single(wf) 19 | -------------------------------------------------------------------------------- /noodles/lib/coroutine.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | 4 | def coroutine(f): 5 | """ 6 | A sink should be send `None` first, so that the coroutine arrives 7 | at the `yield` position. This wrapper takes care that this is done 8 | automatically when the coroutine is started. 9 | """ 10 | @wraps(f) 11 | def g(*args, **kwargs): 12 | sink = f(*args, **kwargs) 13 | sink.send(None) 14 | return sink 15 | 16 | return g 17 | -------------------------------------------------------------------------------- /noodles/run/single/vanilla.py: -------------------------------------------------------------------------------- 1 | from ..worker import (worker) 2 | from ..scheduler import (Scheduler) 3 | from ...lib import (Queue) 4 | from ...workflow import (get_workflow) 5 | 6 | 7 | def run_single(workflow): 8 | """"Run workflow in a single thread (same as the scheduler). 9 | 10 | :param workflow: Workflow or PromisedObject to be evaluated. 11 | :return: Evaluated result. 12 | """ 13 | return Scheduler().run( 14 | Queue() >> worker, 15 | get_workflow(workflow)) 16 | -------------------------------------------------------------------------------- /test/workflows/unpack.py: -------------------------------------------------------------------------------- 1 | from .workflow_factory import workflow_factory 2 | import noodles 3 | from noodles.tutorial import add 4 | 5 | 6 | @workflow_factory(raises=TypeError) 7 | def unguarded_iteration(): 8 | a = noodles.delay((1, 2, 3)) 9 | b, c, d = a 10 | return noodles.gather(d, c, b) 11 | 12 | 13 | @noodles.schedule 14 | def f(): 15 | return 1, 2, 3 16 | 17 | 18 | @workflow_factory(result=6) 19 | def unpack(): 20 | a, b, c = noodles.unpack(f(), 3) 21 | return add(a, add(b, c)) 22 | -------------------------------------------------------------------------------- /examples/example1.py: -------------------------------------------------------------------------------- 1 | from noodles import schedule 2 | from prototype import draw_workflow 3 | 4 | 5 | @schedule 6 | def f(a, b): 7 | return a+b 8 | 9 | 10 | @schedule 11 | def g(a, b): 12 | return a-b 13 | 14 | 15 | @schedule 16 | def h(a, b): 17 | return a*b 18 | 19 | 20 | # run example program 21 | # -------------------- 22 | u = f(5, 4) 23 | v = g(u, 3) 24 | w = g(u, 2) 25 | x = h(v, w) 26 | 27 | 28 | # draw the execution graph 29 | # ------------------------- 30 | draw_workflow("callgraph.png", x) 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thank you for being interested to contribute to Noodles! 2 | 3 | ## Issues / Pull requests 4 | If you find a bug or unexpected behaviour in Noodles you are welcome to open an issue. 5 | We'll do our best to address the issue if it is within our capacity to do so. 6 | 7 | Pull requests are certainly welcome. Please first open an issue outlining the bug or feature request that is being addressed. 8 | 9 | All contributions will be integrated per the Apache License agreement (see LICENSE); contributing authors will so be attributed. 10 | -------------------------------------------------------------------------------- /noodles/serial/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from .numpy import registry as numpy 3 | except ImportError: 4 | numpy = None 5 | 6 | from .pickle import registry as pickle 7 | from .base import registry as base 8 | from .as_dict import AsDict 9 | from .namedtuple import registry as namedtuple 10 | from .registry import (Registry, Serialiser, RefObject) 11 | from .reasonable import (Reasonable) 12 | from .path import SerPath 13 | 14 | __all__ = ['pickle', 'base', 'Registry', 'Serialiser', 'SerPath', 15 | 'RefObject', 'AsDict', 'namedtuple', 'Reasonable', 'numpy'] 16 | -------------------------------------------------------------------------------- /test/workflows/nesting.py: -------------------------------------------------------------------------------- 1 | from .workflow_factory import workflow_factory 2 | import noodles 3 | 4 | 5 | @noodles.schedule 6 | def sqr(a): 7 | return a*a 8 | 9 | 10 | @noodles.schedule 11 | def sum(a, buildin_sum=sum): 12 | return buildin_sum(a) 13 | 14 | 15 | @noodles.schedule 16 | def map(f, lst): 17 | return noodles.gather_all(f(x) for x in lst) 18 | 19 | 20 | @noodles.schedule 21 | def num_range(a, b): 22 | return range(a, b) 23 | 24 | 25 | @workflow_factory(result=285) 26 | def test_higher_order(): 27 | return sum(map(sqr, num_range(0, 10))) 28 | -------------------------------------------------------------------------------- /examples/boil/src/common.hh: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "types.hh" 4 | 5 | extern int iterate(function f, predicate pred, complex z, unsigned maxint); 6 | extern bool pred(complex z); 7 | extern function f(complex c); 8 | extern void render(predicate pred, complex a, complex b, unsigned width); 9 | extern void render_double_colour(unit_map f, complex a, complex b, unsigned width); 10 | 11 | extern predicate mandelbrot(int maxit); 12 | extern predicate julia(complex c, int maxit); 13 | extern unit_map mandelbrot_c(int maxit); 14 | extern unit_map julia_c(complex c, int maxit); 15 | -------------------------------------------------------------------------------- /test/backends/backend_factory.py: -------------------------------------------------------------------------------- 1 | class backend_factory: 2 | def __init__(self, runner, supports=None, *args, **kwargs): 3 | self.runner = runner 4 | self._supports = supports or [] 5 | self.args = args 6 | self.kwargs = kwargs 7 | 8 | def supports(self, requires): 9 | if requires is None: 10 | return True 11 | 12 | return all((requirement in self._supports) 13 | for requirement in requires) 14 | 15 | def run(self, workflow): 16 | return self.runner(workflow, *self.args, **self.kwargs) 17 | -------------------------------------------------------------------------------- /noodles/lib/decorator.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | 4 | def decorator(f): 5 | """Creates a paramatric decorator from a function. The resulting decorator 6 | will optionally take keyword arguments.""" 7 | @functools.wraps(f) 8 | def decoratored_function(*args, **kwargs): 9 | if args and len(args) == 1: 10 | return f(*args, **kwargs) 11 | 12 | if args: 13 | raise TypeError( 14 | "This decorator only accepts extra keyword arguments.") 15 | 16 | return lambda g: f(g, **kwargs) 17 | 18 | return decoratored_function 19 | -------------------------------------------------------------------------------- /test/test_timer.py: -------------------------------------------------------------------------------- 1 | import noodles 2 | from noodles.run.runners import (run_parallel_timing) 3 | from noodles.tutorial import (mul, sub, accumulate) 4 | import time 5 | import sys 6 | 7 | 8 | @noodles.schedule_hint( 9 | display="adding {a} + {b}") 10 | def add(a, b): 11 | time.sleep(0.01) 12 | return a + b 13 | 14 | 15 | def test_xenon_42(): 16 | A = add(1, 1) 17 | B = sub(3, A) 18 | 19 | multiples = [mul(add(i, B), A) for i in range(6)] 20 | C = accumulate(noodles.gather(*multiples)) 21 | 22 | result = run_parallel_timing(C, 4, sys.stdout) 23 | assert(result == 42) 24 | -------------------------------------------------------------------------------- /noodles/workflow/__init__.py: -------------------------------------------------------------------------------- 1 | from .arguments import (Empty, ArgumentKind, Argument, ArgumentAddress) 2 | from .model import ( 3 | Workflow, FunctionNode, NodeData, get_workflow, is_workflow, is_node_ready) 4 | from .mutations import (reset_workflow, insert_result) 5 | from .create import (from_call) 6 | from .graphs import (invert_links) 7 | 8 | __all__ = ['invert_links', 'from_call', 9 | 'Workflow', 'FunctionNode', 'NodeData', 10 | 'get_workflow', 'is_workflow', 'reset_workflow', 11 | 'insert_result', 'Empty', 'is_node_ready', 12 | 'Argument', 'ArgumentAddress', 'ArgumentKind'] 13 | -------------------------------------------------------------------------------- /test/test_results.py: -------------------------------------------------------------------------------- 1 | import noodles 2 | from noodles.tutorial import add, sub, mul, accumulate 3 | from noodles import run_single 4 | 5 | 6 | def test_get_intermediate_results(): 7 | r1 = add(1, 1) 8 | r2 = sub(3, r1) 9 | 10 | def foo(a, b, c): 11 | return mul(add(a, b), c) 12 | 13 | r3 = [foo(i, r2, r1) for i in range(6)] 14 | r4 = accumulate(noodles.gather_all(r3)) 15 | 16 | run_single(r4) 17 | 18 | assert noodles.result(r1) == 2 19 | assert noodles.result(r2) == 1 20 | assert [noodles.result(r) for r in r3] == [2, 4, 6, 8, 10, 12] 21 | assert noodles.result(r4) == 42 22 | -------------------------------------------------------------------------------- /examples/draw/pythagoras.py: -------------------------------------------------------------------------------- 1 | from noodles import (schedule, run_single) 2 | from noodles.tutorial import (add) 3 | from noodles.draw_workflow import draw_workflow 4 | 5 | 6 | @schedule 7 | class A: 8 | def __init__(self, value): 9 | self.value = value 10 | 11 | @property 12 | def square(self): 13 | return self.value**2 14 | 15 | @square.setter 16 | def square(self, sqr): 17 | self.value = sqr**(1/2) 18 | 19 | 20 | u = A(3) 21 | v = A(4) 22 | u.square = add(u.square, v.square) 23 | 24 | draw_workflow("pythagoras.pdf", u.value._workflow) 25 | 26 | print("⎷(3² + 4²) = ", run_single(u.value)) 27 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | source = noodles/ 5 | 6 | [report] 7 | show_missing = True 8 | # Regexes for lines to exclude from consideration 9 | exclude_lines = 10 | # Have to re-enable the standard pragma 11 | pragma: no cover 12 | 13 | # Don't complain about missing debug-only code: 14 | def __repr__ 15 | if self\.debug 16 | 17 | # Don't complain if tests don't hit defensive assertion code: 18 | raise AssertionError 19 | raise NotImplementedError 20 | 21 | # Don't complain if non-runnable code isn't run: 22 | if 0: 23 | if __name__ == .__main__.: 24 | -------------------------------------------------------------------------------- /examples/soba/README.rst: -------------------------------------------------------------------------------- 1 | ============================================== 2 | SOBA: Utility for non-directed graph execution 3 | ============================================== 4 | 5 | Noodles gives a way to execute directed acyclic graphs. There are use cases where 6 | the graph just gives us mutual exclusion of jobs, because they are writing to the 7 | same output location (either memory or disk). In this case we want to do the 8 | scheduling of jobs dynamically, with the exclusion information added as meta-data. 9 | 10 | For this example, we have the graph being fed as a JSON file. We add a worker broker 11 | to the pool to deal with just this problem. 12 | -------------------------------------------------------------------------------- /test/lib/test_queue.py: -------------------------------------------------------------------------------- 1 | from noodles.lib import Queue, EndOfQueue, patch, pull_from 2 | from pytest import raises 3 | 4 | 5 | def test_queue(): 6 | Q = Queue() 7 | patch(pull_from(range(10)) >> (lambda x: x*x), Q.sink) 8 | Q.close() 9 | assert list(Q.source) == [i*i for i in range(10)] 10 | with raises(StopIteration): 11 | next(Q.source()) 12 | 13 | 14 | def test_queue_chaining(): 15 | Q = Queue() >> (lambda x: x*x) 16 | patch(pull_from(range(10)), Q.sink) 17 | try: 18 | Q.sink().send(EndOfQueue) 19 | except StopIteration: 20 | pass 21 | assert list(Q.source) == [i*i for i in range(10)] 22 | -------------------------------------------------------------------------------- /test/workflows/class_methods.py: -------------------------------------------------------------------------------- 1 | from .workflow_factory import workflow_factory 2 | from noodles import ( 3 | schedule, has_scheduled_methods) 4 | 5 | 6 | @has_scheduled_methods 7 | class A(object): 8 | def __init__(self, x): 9 | super().__init__() 10 | self.x = x 11 | 12 | @schedule 13 | def __call__(self, y): 14 | return self.x * y 15 | 16 | def __serialize__(self, pack): 17 | return pack(self.x) 18 | 19 | @classmethod 20 | def __construct__(cls, data): 21 | return cls(data) 22 | 23 | 24 | @workflow_factory(result=42) 25 | def test_class_methods_00(): 26 | a = A(7) 27 | return a(6) 28 | -------------------------------------------------------------------------------- /test/lib/test_coroutine.py: -------------------------------------------------------------------------------- 1 | from noodles.lib import coroutine 2 | 3 | 4 | class EndOfWork(object): 5 | pass 6 | 7 | 8 | def close_coroutine(x): 9 | try: 10 | x.send(EndOfWork) 11 | except StopIteration: 12 | pass 13 | 14 | 15 | def test_coroutine(): 16 | @coroutine 17 | def list_sink(lst): 18 | while True: 19 | value = yield 20 | if value is EndOfWork: 21 | return 22 | lst.append(value) 23 | 24 | a = [] 25 | sink = list_sink(a) 26 | 27 | for i in range(10): 28 | sink.send(i) 29 | close_coroutine(sink) 30 | 31 | assert a == list(range(10)) 32 | -------------------------------------------------------------------------------- /test/serial/test_dictlike.py: -------------------------------------------------------------------------------- 1 | from noodles import serial 2 | from noodles.serial.numpy import arrays_to_string 3 | 4 | 5 | def registry(): 6 | """Serialisation registry for matrix testing backends.""" 7 | return serial.base() + arrays_to_string() 8 | 9 | 10 | class A(dict): 11 | pass 12 | 13 | 14 | def test_encode_dictlike(): 15 | reg = registry() 16 | a = A() 17 | a['value'] = 42 18 | encoded = reg.deep_encode(a) 19 | decoded = reg.deep_decode(encoded, deref=True) 20 | assert isinstance(decoded, A) 21 | assert decoded['value'] == 42 22 | deref = reg.dereference(a) 23 | assert isinstance(deref, A) 24 | assert deref['value'] == 42 25 | -------------------------------------------------------------------------------- /test/workflows/gather.py: -------------------------------------------------------------------------------- 1 | import noodles 2 | from noodles.tutorial import add 3 | from .workflow_factory import workflow_factory 4 | 5 | 6 | @workflow_factory(result=[]) 7 | def empty_gather(): 8 | return noodles.gather() 9 | 10 | 11 | @workflow_factory(result=list(range(0, 20, 2))) 12 | def gather(): 13 | return noodles.gather(*[add(x, x) for x in range(10)]) 14 | 15 | 16 | @workflow_factory(result={'a': 1, 'b': 2, 'c': 5}) 17 | def gather_dict(): 18 | return noodles.gather_dict(a=1, b=add(1, 1), c=add(2, 3)) 19 | 20 | 21 | @workflow_factory(result=list(range(0, 30, 3))) 22 | def test_gather_all(): 23 | return noodles.gather_all(add(x, 2*x) for x in range(10)) 24 | -------------------------------------------------------------------------------- /noodles/interface/__init__.py: -------------------------------------------------------------------------------- 1 | from ..lib import (unwrap) 2 | from .decorator import ( 3 | PromisedObject, schedule, schedule_hint, has_scheduled_methods, 4 | update_hints, result) 5 | from .functions import ( 6 | delay, gather, gather_all, gather_dict, lift, unpack, quote, 7 | unquote, simple_lift, ref, Quote) 8 | from .maybe import (maybe, Fail, failed) 9 | 10 | __all__ = ['delay', 'gather', 'gather_all', 'gather_dict', 'schedule_hint', 11 | 'schedule', 'unpack', 'has_scheduled_methods', 'unwrap', 12 | 'update_hints', 'lift', 'failed', 13 | 'PromisedObject', 'Quote', 'quote', 'unquote', 'result', 14 | 'maybe', 'Fail', 'simple_lift', 'ref'] 15 | -------------------------------------------------------------------------------- /test/workflows/lift.py: -------------------------------------------------------------------------------- 1 | from .workflow_factory import workflow_factory 2 | import noodles 3 | from noodles.tutorial import (add, sub) 4 | from collections import OrderedDict 5 | 6 | 7 | @noodles.schedule 8 | def g(x): 9 | return x['a'] + x['b'] 10 | 11 | 12 | @workflow_factory(result=4) 13 | def lift_ordered_dict(): 14 | x = OrderedDict() 15 | x['a'] = 1 16 | x['b'] = add(1, 2) 17 | return g(noodles.lift(x)) 18 | 19 | 20 | class A: 21 | pass 22 | 23 | 24 | @noodles.schedule 25 | def f(a): 26 | return a.x + a.y 27 | 28 | 29 | @workflow_factory(result=1) 30 | def lift_object(): 31 | a = A() 32 | a.x = add(1, 2) 33 | a.y = sub(9, 11) 34 | return f(noodles.lift(a)) 35 | -------------------------------------------------------------------------------- /noodles/run/threading/vanilla.py: -------------------------------------------------------------------------------- 1 | from ..worker import worker 2 | from ..scheduler import Scheduler 3 | from ...lib import (Queue, thread_pool) 4 | from ...workflow import get_workflow 5 | 6 | from itertools import repeat 7 | 8 | 9 | def run_parallel(workflow, n_threads): 10 | """Run a workflow in parallel threads. 11 | 12 | :param workflow: Workflow or PromisedObject to evaluate. 13 | :param n_threads: number of threads to use (in addition to the scheduler). 14 | :returns: evaluated workflow. 15 | """ 16 | scheduler = Scheduler() 17 | threaded_worker = Queue() >> thread_pool( 18 | *repeat(worker, n_threads)) 19 | 20 | return scheduler.run(threaded_worker, get_workflow(workflow)) 21 | -------------------------------------------------------------------------------- /test/workflows/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | gather, conditionals, class_methods, unpack, lift, dict_likes, 3 | recursion, nesting, capture_output, patterns, setters, operators) 4 | from .workflow_factory import workflow_factory 5 | from itertools import chain 6 | from noodles.lib import unwrap 7 | 8 | 9 | modules = [ 10 | gather, conditionals, class_methods, unpack, lift, dict_likes, 11 | recursion, nesting, capture_output, patterns, setters, operators 12 | ] 13 | 14 | workflows = dict(chain.from_iterable( 15 | ((item, getattr(module, item)) for item in dir(module) 16 | if isinstance(getattr(module, item), unwrap(workflow_factory))) 17 | for module in modules)) 18 | 19 | __all__ = ['workflows'] 20 | -------------------------------------------------------------------------------- /test/serial/test_remote_io.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing JSON serialisation writer and reader. 3 | """ 4 | 5 | import io 6 | import math 7 | 8 | from noodles.run.remote.io import (JSONObjectReader, JSONObjectWriter) 9 | from noodles.serial import base as registry 10 | 11 | 12 | objects = ["Hello", 42, [3, 4], (5, 6), {"hello": "world"}, 13 | math.tan, object] 14 | 15 | 16 | def test_json(): 17 | """Test streaming JSON objects.""" 18 | f = io.StringIO() 19 | output_stream = JSONObjectWriter(registry(), f) 20 | 21 | for obj in objects: 22 | output_stream.send(obj) 23 | 24 | f.seek(0) 25 | input_stream = JSONObjectReader(registry(), f) 26 | 27 | new_objects = list(input_stream) 28 | 29 | assert new_objects == objects 30 | -------------------------------------------------------------------------------- /test/test_scheduled_method.py: -------------------------------------------------------------------------------- 1 | from noodles import (schedule, Scheduler, has_scheduled_methods, serial) 2 | from noodles.serial import (Registry, AsDict) 3 | from noodles.workflow import get_workflow 4 | from noodles.run.process import process_worker 5 | 6 | 7 | def registry(): 8 | reg = Registry(parent=serial.base()) 9 | reg[A] = AsDict(A) 10 | return reg 11 | 12 | 13 | @has_scheduled_methods 14 | class A: 15 | def __init__(self, a): 16 | super(A, self).__init__() 17 | self.a = a 18 | 19 | @schedule 20 | def mul(self, b): 21 | return self.a * b 22 | 23 | 24 | def test_sched_meth(): 25 | a = A(5) 26 | b = a.mul(5) 27 | result = Scheduler().run(process_worker(registry), get_workflow(b)) 28 | assert result == 25 29 | -------------------------------------------------------------------------------- /test/test_deep_map.py: -------------------------------------------------------------------------------- 1 | from noodles.lib import deep_map 2 | 3 | 4 | class A: 5 | def __init__(self, value): 6 | self.value = value 7 | 8 | 9 | class B(A): 10 | pass 11 | 12 | 13 | def translate(a): 14 | if isinstance(a, A): 15 | name = type(a).__name__ 16 | return {'type': name, 'value': a.value} 17 | 18 | else: 19 | return a 20 | 21 | 22 | def test_deep_map(): 23 | a = A(5) 24 | b = A("Hello") 25 | c = B(67.372) 26 | d = B([a, b]) 27 | e = A({'one': c, 'two': d}) 28 | 29 | result = deep_map(translate, e) 30 | print(result) 31 | assert result['value']['two']['value'][0]['value'] == 5 32 | assert result['value']['one']['type'] == 'B' 33 | assert result['value']['two']['value'][1]['value'] == "Hello" 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "noodles" 3 | version = "0.3.4" 4 | description = "Worflow Engine" 5 | authors = ["Johan Hidding "] 6 | license = "Apache 2" 7 | 8 | [tool.poetry.dependencies] 9 | python = ">=3.8,<4.0" 10 | pyxenon = "^3.0.3" 11 | numpy = "^1.22.2" 12 | h5py = "^3.6.0" 13 | filelock = "^3.4.2" 14 | graphviz = "^0.19.1" 15 | 16 | [tool.poetry.dev-dependencies] 17 | pytest = "^7.0.0" 18 | coverage = "^6.3.1" 19 | pep8 = "^1.7.1" 20 | Sphinx = "^4.4.0" 21 | sphinx-rtd-theme = "^1.0.0" 22 | nbsphinx = "^0.8.8" 23 | flake8 = "^4.0.1" 24 | pytest-flake8 = "^1.0.7" 25 | 26 | [build-system] 27 | requires = ["poetry-core>=1.0.0"] 28 | build-backend = "poetry.core.masonry.api" 29 | 30 | [tool.pytest.ini_options] 31 | testpaths = [ 32 | "test" 33 | ] 34 | -------------------------------------------------------------------------------- /test/serial/test_namedtuple.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from noodles import serial 3 | from noodles.serial.namedtuple import (SerNamedTuple) 4 | from noodles.serial import (Registry) 5 | 6 | A = namedtuple('A', ['x', 'y']) 7 | B = namedtuple('B', ['u', 'v']) 8 | 9 | 10 | def registry1(): 11 | return Registry( 12 | parent=serial.base(), 13 | types={ 14 | A: SerNamedTuple(A) 15 | }) 16 | 17 | 18 | def registry2(): 19 | return serial.base() + serial.namedtuple() 20 | 21 | 22 | def test_namedtuple_00(): 23 | a = A(73, 38) 24 | r = registry1() 25 | assert r.deep_decode(r.deep_encode(a)) == a 26 | 27 | b = B(783, 837) 28 | r = registry2() 29 | print(r.deep_encode(b)) 30 | assert r.deep_decode(r.deep_encode(b)) == b 31 | -------------------------------------------------------------------------------- /test/lib/test_utility.py: -------------------------------------------------------------------------------- 1 | from noodles.lib import (object_name, look_up, importable) 2 | import math 3 | from pytest import raises 4 | 5 | 6 | def test_object_name(): 7 | assert object_name(math.sin) == 'math.sin' 8 | assert object_name(object_name) == 'noodles.lib.utility.object_name' 9 | 10 | with raises(AttributeError): 11 | object_name(1) 12 | 13 | 14 | def test_look_up(): 15 | from pathlib import Path 16 | assert look_up('math.sin') is math.sin 17 | assert look_up('pathlib.Path') is Path 18 | 19 | 20 | def test_importable(): 21 | import os 22 | assert not importable(3) 23 | assert importable(math.cos) 24 | assert not importable(lambda x: x*x) 25 | assert importable(importable) 26 | assert importable(raises) 27 | assert not importable(os.name) 28 | -------------------------------------------------------------------------------- /test/lib/test_decorator.py: -------------------------------------------------------------------------------- 1 | from noodles.lib import decorator 2 | import math 3 | from pytest import approx, raises 4 | 5 | 6 | def test_decorator(): 7 | @decorator 8 | def diff(f, delta=1e-6): 9 | def df(x): 10 | return (f(x + delta) - f(x - delta)) / (2 * delta) 11 | 12 | return df 13 | 14 | @diff 15 | def f(x): 16 | return x**2 17 | 18 | assert f(4) == approx(8) 19 | assert f(3) == approx(6) 20 | 21 | @diff(delta=1e-8) 22 | def g(x): 23 | return math.sin(x) 24 | 25 | assert g(0.5) == approx(math.cos(0.5)) 26 | 27 | with raises(TypeError): 28 | @diff(r=10) 29 | def q(x): 30 | return x 31 | 32 | with raises(TypeError): 33 | @diff(30, 4) 34 | def r(x): 35 | return x 36 | -------------------------------------------------------------------------------- /examples/noodles.ini: -------------------------------------------------------------------------------- 1 | [default] 2 | machine=local 3 | 4 | [Machines] 5 | [local] 6 | runner=parallel 7 | features=prov, display 8 | n_threads=4 9 | 10 | [debug] 11 | runner=process 12 | features=msgpack 13 | verbose=True 14 | 15 | [cartesius] 16 | runner=xenon 17 | features=prov, display 18 | host=cartesius.surfsara.nl 19 | scheme=slurm 20 | user=jhidding 21 | n_jobs=1 22 | n_threads_per_job=16 23 | 24 | [das5] 25 | runner=xenon 26 | featuers=prov, display 27 | host=fs0.das5.cs.vu.nl 28 | scheme=slurm 29 | user=jhidding 30 | 31 | [Users] 32 | [] 33 | username= 34 | protocol=ssh 35 | certificate=~/.ssh/id_rsa 36 | -------------------------------------------------------------------------------- /doc/source/development.rst: -------------------------------------------------------------------------------- 1 | Development documentation 2 | ========================= 3 | .. automodule:: noodles 4 | :members: 5 | 6 | Internal Specs 7 | -------------- 8 | .. automodule:: noodles.workflow 9 | :members: 10 | 11 | Promised object 12 | --------------- 13 | .. automodule:: noodles.interface 14 | :members: 15 | 16 | Runners 17 | ------- 18 | .. automodule:: noodles.run.scheduler 19 | :members: 20 | 21 | .. automodule:: noodles.run.hybrid 22 | :members: 23 | 24 | Serialisation 25 | ------------- 26 | .. automodule:: noodles.serial 27 | :members: 28 | 29 | .. automodule:: noodles.serial.registry 30 | :members: 31 | 32 | Worker executable 33 | ----------------- 34 | .. automodule:: noodles.worker 35 | :members: 36 | 37 | Streams 38 | ------- 39 | .. automodule:: noodles.lib 40 | :members: 41 | -------------------------------------------------------------------------------- /examples/boil/test/test.hh: -------------------------------------------------------------------------------- 1 | #pragma once 2 | #include 3 | #include 4 | 5 | extern int __assert_fail(std::string const &expr, std::string const &file, int lineno); 6 | 7 | #define assert(e) ((void) ((e) ? 0 : __assert_fail (#e, __FILE__, __LINE__))) 8 | 9 | class Test { 10 | typedef std::map imap; 11 | static std::unique_ptr _instances; 12 | 13 | std::string const _name, _description; 14 | std::function const code; 15 | 16 | public: 17 | static imap &instances(); 18 | static void all(bool); 19 | 20 | ~Test(); 21 | Test(std::string const &name_, std::function const &code_); 22 | 23 | bool operator()() const { 24 | return code(); 25 | } 26 | 27 | std::string const &name() const { 28 | return _name; 29 | } 30 | }; 31 | 32 | -------------------------------------------------------------------------------- /noodles/serial/reasonable.py: -------------------------------------------------------------------------------- 1 | from .registry import Serialiser 2 | 3 | 4 | class Reasonable(object): 5 | """A Reasonable object is an object which is most reasonably serialised 6 | using its `__dict__` property. To deserialise the object, we first create 7 | an instance using the `__new__` method, then setting the `__dict__` 8 | property manualy. This class is empty, it is used as a tag to designate 9 | other objects as reasonable.""" 10 | pass 11 | 12 | 13 | class SerReasonableObject(Serialiser): 14 | def __init__(self, cls): 15 | super(SerReasonableObject, self).__init__(cls) 16 | 17 | def encode(self, obj, make_rec): 18 | return make_rec(obj.__dict__) 19 | 20 | def decode(self, cls, data): 21 | obj = cls.__new__(cls) 22 | obj.__dict__ = data 23 | return obj 24 | -------------------------------------------------------------------------------- /test/test_global_prov.py: -------------------------------------------------------------------------------- 1 | from noodles.prov.workflow import (set_global_provenance) 2 | from noodles.tutorial import (add, sub, mul) 3 | from noodles.serial import base 4 | 5 | 6 | def test_global_prov(): 7 | a = add(4, 5) 8 | b = sub(a, 3) 9 | c = mul(b, 7) 10 | d = mul(b, 7) 11 | e = sub(c, 3) 12 | f = sub(c, 5) 13 | 14 | set_global_provenance(c._workflow, base()) 15 | 16 | assert c._workflow.prov is not None 17 | assert b._workflow.prov is not None 18 | assert c._workflow.prov != b._workflow.prov 19 | 20 | set_global_provenance(d._workflow, base()) 21 | set_global_provenance(e._workflow, base()) 22 | set_global_provenance(f._workflow, base()) 23 | 24 | assert c._workflow.prov == d._workflow.prov 25 | assert b._workflow.prov != e._workflow.prov 26 | assert f._workflow.prov != e._workflow.prov 27 | -------------------------------------------------------------------------------- /test/test_globals.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the use of init and finish functions. 3 | """ 4 | 5 | from noodles import schedule, run_process, serial 6 | 7 | 8 | def init(): 9 | """Creates a global variable ``s``, and returns True.""" 10 | global S 11 | S = "This global variable needs to be here!" 12 | return True 13 | 14 | 15 | def finish(): 16 | """Just print a message.""" 17 | return "Finish functino was run!" 18 | 19 | 20 | @schedule 21 | def checker(): 22 | """Check if global variable ``s`` exists.""" 23 | return S == "This global variable needs to be here!" 24 | 25 | 26 | def test_globals(): 27 | """Test init and finish functions on ``run_process``.""" 28 | a = checker() 29 | result = run_process(a, n_processes=1, registry=serial.base, 30 | init=init, finish=finish) 31 | assert result 32 | -------------------------------------------------------------------------------- /test/workflows/workflow_factory.py: -------------------------------------------------------------------------------- 1 | from noodles.lib import decorator 2 | 3 | 4 | @decorator 5 | class workflow_factory: 6 | instances = [] 7 | 8 | def __init__(self, f, result=None, assertions=None, raises=None, 9 | requires=None, python_version=None): 10 | self.instances.append(self) 11 | self.assertions = assertions 12 | self.requires = requires 13 | self.result = result 14 | self.raises = raises 15 | self.python_version = python_version 16 | self.f = f 17 | 18 | def make(self): 19 | return self.f() 20 | 21 | def check_assertions(self, result): 22 | if self.result is not None: 23 | assert result == self.result 24 | 25 | if self.assertions is not None: 26 | for assertion in self.assertions: 27 | assert assertion(result) 28 | -------------------------------------------------------------------------------- /examples/boil/README.rst: -------------------------------------------------------------------------------- 1 | Noodles build system: BOIL 2 | ========================== 3 | 4 | This example shows how to enhance your daily workflow with Noodles! The program 5 | reads a file `boil.ini` from the current directory and starts to compile your 6 | favourite C/C++ project. Dependencies for each source file are being checked 7 | using `gcc -MM` and files are only compiled when the dependcies are newer. 8 | The user can give the `-j` parameter to determine how many jobs should run 9 | simultaneously. 10 | 11 | If you look at the BOIL source code, you may notice that very little of the 12 | parallelism of this code creeps into the basic logic of the program. 13 | 14 | 15 | Testing BOIL 16 | ~~~~~~~~~~~~ 17 | 18 | To test BOIL we provided a small C++ program. There is a main program and 19 | a unittest which can be compiled separately using the given configuration 20 | in `boil.ini`. -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | charset = utf-8 12 | 13 | # Matches multiple files with brace expansion notation 14 | # Set default charset 15 | [*.{js,py,java,r,R}] 16 | indent_style = space 17 | 18 | # 4 space indentation 19 | [*.py] 20 | indent_size = 4 21 | 22 | # Tab indentation (no size specified) 23 | [*.js] 24 | indent_size = 2 25 | 26 | # Matches the exact files either package.json or .travis.yml 27 | [*.{json,yml}] 28 | indent_size = 2 29 | 30 | [*.{md,Rmd}] 31 | trim_trailing_whitespace = true 32 | 33 | [*.{cpp,hpp,cc,hh,c,h,cuh,cu}] 34 | indent_size = 4 35 | indent_style = space 36 | 37 | [*.{sls,scm,ss}] 38 | indent_size = 2 39 | indent_style = space 40 | 41 | -------------------------------------------------------------------------------- /noodles/serial/pickle.py: -------------------------------------------------------------------------------- 1 | from .registry import (Serialiser, Registry) 2 | 3 | import base64 4 | import pickle 5 | 6 | 7 | class PickleString(Serialiser): 8 | def __init__(self, cls): 9 | super(PickleString, self).__init__(cls) 10 | 11 | def encode(self, obj, make_rec): 12 | data = base64.b64encode(pickle.dumps(obj)).decode('ascii') 13 | return make_rec(data) 14 | 15 | def decode(self, cls, data): 16 | return pickle.loads( 17 | base64.b64decode(data.encode('ascii'))) 18 | 19 | 20 | def registry(): 21 | """Returns a serialisation registry that "just pickles everything". 22 | 23 | This registry can be used to bolt-on other registries and keep the 24 | pickle as the default. The objects are first pickled to a byte-array, 25 | which is subsequently encoded with base64.""" 26 | return Registry( 27 | default=PickleString(object) 28 | ) 29 | -------------------------------------------------------------------------------- /test/test_matrix.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test available runners on a variety of workflows. 3 | """ 4 | 5 | import pytest 6 | import sys 7 | 8 | 9 | def test_run(workflow, backend): 10 | if not backend.supports(workflow.requires): 11 | pytest.skip("Workflow not supported on this backend.") 12 | 13 | if workflow.python_version is not None and \ 14 | not sys.version_info >= workflow.python_version: 15 | required = ".".join(map(str, workflow.python_version)) 16 | running = ".".join(map(str, sys.version_info[0:3])) 17 | pytest.skip("Workflow requires Python >= {}, running Python {}." 18 | .format(required, running)) 19 | 20 | if workflow.raises is not None: 21 | with pytest.raises(workflow.raises): 22 | backend.run(workflow.make()) 23 | else: 24 | result = backend.run(workflow.make()) 25 | workflow.check_assertions(result) 26 | -------------------------------------------------------------------------------- /noodles/workflow/mutations.py: -------------------------------------------------------------------------------- 1 | from .arguments import set_argument, Empty 2 | 3 | 4 | def reset_workflow(workflow): 5 | for tgt in workflow.links.values(): 6 | for m, a in tgt: 7 | set_argument(workflow.nodes[m].bound_args, a, Empty) 8 | 9 | return workflow 10 | 11 | 12 | def insert_result(node, address, value): 13 | """Runs `set_argument`, but checks first wether the data location is not 14 | already filled with some data. In any normal circumstance this checking 15 | is redundant, but if we don't give an error here the program would continue 16 | with unexpected results. 17 | """ 18 | # a = ref_argument(node.bound_args, address) 19 | # if a != Empty: 20 | # raise RuntimeError( 21 | # "Noodle panic. Argument {arg} in {name} already given." \ 22 | # .format(arg=format_address(address), 23 | # name=node.foo.__name__)) 24 | 25 | set_argument(node.bound_args, address, value) 26 | -------------------------------------------------------------------------------- /scripts/annotate-code-blocks.lua: -------------------------------------------------------------------------------- 1 | local vars = {} 2 | 3 | function CodeBlock (elem) 4 | title = nil 5 | do_annotate = false 6 | 7 | if elem.identifier and elem.identifier ~= "" then 8 | if vars[elem.identifier] then 9 | title = pandoc.Str ("«" .. elem.identifier .. "»=+") 10 | else 11 | vars[elem.identifier] = true 12 | title = pandoc.Str ("«" .. elem.identifier .. "»=") 13 | end 14 | do_annotate = true 15 | end 16 | 17 | for k, v in pairs(elem.attr[3]) do 18 | if k == "file" then 19 | title = pandoc.Str ("file: «" .. v .. "»=") 20 | do_annotate = true 21 | end 22 | end 23 | elem.attr[3] = {} 24 | 25 | if do_annotate then 26 | header_attrs = pandoc.Attr(nil, {"noweb"}) 27 | header = pandoc.Div(pandoc.Para {title}, header_attrs) 28 | return { header, elem } 29 | else 30 | return elem 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /noodles/display/pretty.py: -------------------------------------------------------------------------------- 1 | # from .termapp import TermApp 2 | 3 | 4 | class Display: 5 | def __init__(self): 6 | pass 7 | 8 | def report(self): 9 | pass 10 | 11 | def __call__(self, msg): 12 | key, status, data, err_msg = msg 13 | getattr(self, status)(key, data, err_msg) 14 | 15 | def __enter__(self): 16 | pass 17 | 18 | def __exit__(self, exc_type, exc_val, exc_tb): 19 | if exc_type: 20 | if exc_type is KeyboardInterrupt: 21 | self.out << "\n" << ['fg', 255, 200, 50] \ 22 | << "User interrupt detected, abnormal exit.\n" \ 23 | << ['reset'] 24 | return True 25 | 26 | if exc_type is SystemExit: 27 | return False 28 | 29 | print("Internal error encountered. Contact the developers: \n", 30 | exc_type, exc_val) 31 | return False 32 | 33 | self.report() 34 | -------------------------------------------------------------------------------- /test/workflows/recursion.py: -------------------------------------------------------------------------------- 1 | from .workflow_factory import workflow_factory 2 | import noodles 3 | from math import log, floor 4 | from noodles.tutorial import add 5 | 6 | 7 | @noodles.schedule 8 | def mul(a, b): 9 | return a * b 10 | 11 | 12 | @noodles.schedule 13 | def factorial(n): 14 | if n == 0: 15 | return 1 16 | else: 17 | return mul(n, factorial(n - 1)) 18 | 19 | 20 | @noodles.schedule 21 | def floor_log(x): 22 | return floor(log(x)) 23 | 24 | 25 | @workflow_factory(result=148) 26 | def recursion(): 27 | return floor_log(factorial(50.0)) 28 | 29 | 30 | @noodles.schedule(store=True) 31 | def fibonacci(n): 32 | if n < 2: 33 | return 1 34 | else: 35 | return add(fibonacci(n-1), fibonacci(n-2)) 36 | 37 | 38 | @workflow_factory(result=89) 39 | def small_fibonacci(): 40 | return fibonacci(10) 41 | 42 | 43 | @workflow_factory(result=10946, requires=['prov']) 44 | def dynamic_fibonacci(): 45 | return fibonacci(20) 46 | -------------------------------------------------------------------------------- /notebooks/first_steps-workflow-a.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 139745624620896 14 | 15 | add 16 | (1, 1) 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/example2.py: -------------------------------------------------------------------------------- 1 | from noodles import schedule, gather, serial 2 | from noodles.run.run_with_prov import run_parallel_opt 3 | from noodles.display import (DumbDisplay) 4 | 5 | 6 | @schedule 7 | def add(a, b): 8 | return a+b 9 | 10 | 11 | @schedule 12 | def sub(a, b): 13 | return a-b 14 | 15 | 16 | @schedule 17 | def mul(a, b): 18 | return a*b 19 | 20 | 21 | @schedule 22 | def my_sum(a, buildin_sum=sum): 23 | return buildin_sum(a) 24 | 25 | 26 | # a bit more complicated example 27 | # ------------------------------- 28 | r1 = add(1, 1) 29 | r2 = sub(3, r1) 30 | 31 | 32 | def foo(a, b, c): 33 | return mul(add(a, b), c) 34 | 35 | 36 | multiples = [foo(i, r2, r1) for i in range(6)] 37 | 38 | r5 = my_sum(gather(*multiples)) 39 | 40 | # draw_workflow("graph-example2.svg", r5) 41 | 42 | with DumbDisplay() as display: 43 | # answer = run_logging(r5, 4, display) 44 | answer = run_parallel_opt( 45 | r5, 4, serial.base, "cache.json", 46 | display=display, cache_all=True) 47 | 48 | print("The answer is: {0}".format(answer)) 49 | -------------------------------------------------------------------------------- /test/workflows/conditionals.py: -------------------------------------------------------------------------------- 1 | from .workflow_factory import workflow_factory 2 | import noodles 3 | from noodles.tutorial import (add, sub, mul) 4 | 5 | 6 | def cond(t, wt, wf): 7 | return s_cond(t, noodles.quote(wt), noodles.quote(wf)) 8 | 9 | 10 | @noodles.schedule 11 | def s_cond(truth, when_true, when_false): 12 | if truth: 13 | return noodles.unquote(when_true) 14 | else: 15 | return noodles.unquote(when_false) 16 | 17 | 18 | @noodles.schedule 19 | def should_not_run(): 20 | raise RuntimeError("This function should never have been called.") 21 | 22 | 23 | @workflow_factory(result=1) 24 | def truthfulness(): 25 | w = sub(4, add(3, 2)) 26 | a = cond(True, w, should_not_run()) 27 | b = cond(False, should_not_run(), w) 28 | return mul(a, b) 29 | 30 | 31 | def is_sixteen(n): 32 | return n == 16 33 | 34 | 35 | @noodles.schedule 36 | def sqr(x): 37 | return x*x 38 | 39 | 40 | @workflow_factory(result=16) 41 | def find_first(): 42 | wfs = [sqr(x) for x in range(10)] 43 | return noodles.find_first(is_sixteen, wfs) 44 | -------------------------------------------------------------------------------- /examples/class-decorator.py: -------------------------------------------------------------------------------- 1 | from noodles import schedule, run_single 2 | from noodles.draw_workflow import draw_workflow 3 | 4 | 5 | @schedule 6 | def sqr(x): 7 | return x*x 8 | 9 | 10 | @schedule 11 | def divide(x, y): 12 | return x/y 13 | 14 | 15 | @schedule 16 | def mul(x, y): 17 | return x*y 18 | 19 | 20 | @schedule 21 | class A: 22 | def __init__(self, value): 23 | self.x = value 24 | 25 | def multiply(self, factor): 26 | self.x *= factor 27 | return self 28 | 29 | @property 30 | def attr(self): 31 | return sqr(self.__attr) 32 | 33 | def mul_attr(self, factor=1): 34 | return mul(self.__attr, factor) 35 | 36 | @attr.setter 37 | def attr(self, x): 38 | self.__attr = divide(x, 2) 39 | 40 | 41 | @schedule 42 | class B: 43 | pass 44 | 45 | 46 | a = A(5).multiply(10) 47 | a.second = 7 48 | a.attr = 1.0 49 | 50 | b = B() 51 | b.x = a.x 52 | b.second = a.second 53 | b.attr = a.attr 54 | 55 | draw_workflow("oop-wf.svg", b._workflow) 56 | result = run_single(b) 57 | 58 | print(result.x, result.second, result.attr) 59 | -------------------------------------------------------------------------------- /test/xenon/test_run_xenon.py: -------------------------------------------------------------------------------- 1 | from noodles.run.xenon import ( 2 | Machine, XenonJobConfig, run_xenon_simple, run_xenon) 3 | from noodles import gather_all, schedule 4 | from noodles.tutorial import (add, sub, mul) 5 | 6 | 7 | def test_xenon_42_simple(xenon_server): 8 | A = add(1, 1) 9 | B = sub(3, A) 10 | 11 | multiples = [mul(add(i, B), A) for i in range(6)] 12 | C = schedule(sum)(gather_all(multiples)) 13 | 14 | machine = Machine() 15 | worker_config = XenonJobConfig(verbose=True) 16 | 17 | result = run_xenon_simple( 18 | C, machine=machine, worker_config=worker_config) 19 | 20 | assert(result == 42) 21 | 22 | 23 | def test_xenon_42_multi(xenon_server): 24 | A = add(1, 1) 25 | B = sub(3, A) 26 | 27 | multiples = [mul(add(i, B), A) for i in range(6)] 28 | C = schedule(sum)(gather_all(multiples)) 29 | 30 | machine = Machine() 31 | worker_config = XenonJobConfig(queue_name='multi', verbose=True) 32 | 33 | result = run_xenon( 34 | C, machine=machine, worker_config=worker_config, 35 | n_processes=2) 36 | 37 | assert(result == 42) 38 | -------------------------------------------------------------------------------- /examples/static_sum.py: -------------------------------------------------------------------------------- 1 | from noodles import (run_parallel, schedule) 2 | from noodles.tutorial import (add) 3 | 4 | # import numpy as np 5 | 6 | 7 | def static_sum(values, limit_n=1000): 8 | """Example of static sum routine.""" 9 | if len(values) < limit_n: 10 | return sum(values) 11 | 12 | else: 13 | half = len(values) // 2 14 | return add( 15 | static_sum(values[:half], limit_n), 16 | static_sum(values[half:], limit_n)) 17 | 18 | 19 | @schedule 20 | def dynamic_sum(values, limit_n=1000, acc=0, depth=4): 21 | """Example of dynamic sum.""" 22 | if len(values) < limit_n: 23 | return acc + sum(values) 24 | 25 | if depth > 0: 26 | half = len(values) // 2 27 | return add( 28 | dynamic_sum(values[:half], limit_n, acc, depth=depth-1), 29 | dynamic_sum(values[half:], limit_n, 0, depth=depth-1)) 30 | 31 | return dynamic_sum(values[limit_n:], limit_n, 32 | acc + sum(values[:limit_n]), depth) 33 | 34 | 35 | result = run_parallel(dynamic_sum(range(1000000000), 1000000), 4) 36 | print(result) 37 | -------------------------------------------------------------------------------- /noodles/prov/key.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | try: 4 | import ujson as json 5 | except ImportError: 6 | import json 7 | 8 | 9 | def update_object_hash(m, obj): 10 | r = json.dumps(obj, sort_keys=True) 11 | m.update(r.encode()) 12 | return m 13 | 14 | 15 | def prov_key(job_msg, extra=None): 16 | """Retrieves a MD5 sum from a function call. This takes into account the 17 | name of the function, the arguments and possibly a version number of the 18 | function, if that is given in the hints. 19 | This version can also be auto-generated by generating an MD5 hash from the 20 | function source. However, the source-code may not always be reachable, or 21 | the result may depend on an external process which has its own 22 | versioning.""" 23 | m = hashlib.md5() 24 | update_object_hash(m, job_msg['data']['function']) 25 | update_object_hash(m, job_msg['data']['arguments']) 26 | 27 | if 'version' in job_msg['data']['hints']: 28 | update_object_hash(m, job_msg['data']['hints']['version']) 29 | 30 | if extra is not None: 31 | update_object_hash(m, extra) 32 | 33 | return m.hexdigest() 34 | -------------------------------------------------------------------------------- /notebooks/hard_and_easy.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Mixing the easy and the hard\n", 8 | "\n", 9 | "Often in computation, a program is a mix of easy and hard problems. Some jobs only take a fraction of a second, others hours on end. It may be beneficial to mix these in a way that the hard jobs are outsourced to a compute node, while we don't want to spend minutes or even days waiting in the queue to do something very simple." 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": null, 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [] 18 | } 19 | ], 20 | "metadata": { 21 | "kernelspec": { 22 | "display_name": "Python 3", 23 | "language": "python", 24 | "name": "python3" 25 | }, 26 | "language_info": { 27 | "codemirror_mode": { 28 | "name": "ipython", 29 | "version": 3 30 | }, 31 | "file_extension": ".py", 32 | "mimetype": "text/x-python", 33 | "name": "python", 34 | "nbconvert_exporter": "python", 35 | "pygments_lexer": "ipython3", 36 | "version": "3.7.3" 37 | } 38 | }, 39 | "nbformat": 4, 40 | "nbformat_minor": 2 41 | } 42 | -------------------------------------------------------------------------------- /noodles/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | (stub) 3 | 4 | Noodles 5 | ======= 6 | """ 7 | 8 | from noodles.interface import ( 9 | delay, gather, lift, schedule, schedule_hint, unwrap, 10 | has_scheduled_methods, update_hints, unpack, quote, unquote, 11 | gather_dict, result, gather_all, maybe, Fail, simple_lift, ref, failed) 12 | 13 | from noodles.workflow import (get_workflow) 14 | from .patterns import (fold, find_first, conditional) 15 | from .run.runners import run_parallel_with_display as run_logging 16 | 17 | from .run.threading.vanilla import run_parallel 18 | from .run.single.vanilla import run_single 19 | 20 | from .run.process import run_process 21 | from .run.scheduler import Scheduler 22 | 23 | __version__ = "0.3.0" 24 | 25 | __all__ = ['schedule', 'schedule_hint', 'run_single', 'run_process', 26 | 'Scheduler', 'has_scheduled_methods', 27 | 'Fail', 'failed', 28 | 'run_logging', 'run_parallel', 'unwrap', 'get_workflow', 29 | 'gather', 'gather_all', 'gather_dict', 'lift', 'unpack', 30 | 'maybe', 'delay', 'update_hints', 31 | 'quote', 'unquote', 'result', 'fold', 'find_first', 32 | 'conditional', 33 | 'simple_lift', 'ref'] 34 | -------------------------------------------------------------------------------- /noodles/run/worker.py: -------------------------------------------------------------------------------- 1 | from ..lib import (pull_map, EndOfQueue) 2 | from .messages import (ResultMessage, JobMessage) 3 | import sys 4 | 5 | 6 | @pull_map 7 | def worker(job): 8 | """Primary |worker| coroutine. This is a |pull| object that pulls jobs from 9 | a source and yield evaluated results. 10 | 11 | Input should be of type |JobMessage|, output of type |ResultMessage|. 12 | 13 | .. |worker| replace:: :py:func::`worker`""" 14 | if job is EndOfQueue: 15 | return 16 | 17 | if not isinstance(job, JobMessage): 18 | print("Warning: Job should be communicated using `JobMessage`.", 19 | file=sys.stderr) 20 | 21 | key, node = job 22 | return run_job(key, node) 23 | 24 | 25 | def run_job(key, node): 26 | """Run a job. This applies the function node, and returns a |ResultMessage| 27 | when complete. If an exception is raised in the job, the |ResultMessage| 28 | will have ``'error'`` status. 29 | 30 | .. |run_job| replace:: :py:func:`run_job`""" 31 | try: 32 | result = node.apply() 33 | return ResultMessage(key, 'done', result, None) 34 | 35 | except Exception as exc: 36 | return ResultMessage(key, 'error', None, exc) 37 | -------------------------------------------------------------------------------- /noodles/serial/namedtuple.py: -------------------------------------------------------------------------------- 1 | from .registry import (Registry, Serialiser) 2 | from ..lib import (object_name, look_up) 3 | 4 | 5 | class SerNamedTuple(Serialiser): 6 | def __init__(self, cls): 7 | super(SerNamedTuple, self).__init__(cls) 8 | 9 | def encode(self, obj, make_rec): 10 | return make_rec(tuple(obj)) 11 | 12 | def decode(self, cls, data): 13 | return cls(*data) 14 | 15 | 16 | class SerAutoNamedTuple(Serialiser): 17 | def __init__(self): 18 | super(SerAutoNamedTuple, self).__init__('') 19 | 20 | def encode(self, obj, make_rec): 21 | return make_rec({ 22 | 'name': object_name(type(obj)), 23 | 'data': tuple(obj)}) 24 | 25 | def decode(self, cls, data): 26 | return look_up(data['name'])(*data['data']) 27 | 28 | 29 | def is_namedtuple(obj): 30 | return isinstance(obj, tuple) and hasattr(obj, '_fields') 31 | 32 | 33 | def namedtuple_hook(obj): 34 | if is_namedtuple(obj): 35 | return '' 36 | else: 37 | return None 38 | 39 | 40 | def registry(): 41 | return Registry( 42 | hooks={ 43 | '': SerAutoNamedTuple() 44 | }, 45 | hook_fn=namedtuple_hook) 46 | -------------------------------------------------------------------------------- /scripts/list.lua: -------------------------------------------------------------------------------- 1 | -- Copyright 2018 Johan Hidding 2 | 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | -- filename: list.lua 16 | -- description: Pandoc filter that lists all "file" attributes given 17 | -- with code block entries. 18 | 19 | local files = {} 20 | 21 | function CodeBlock (elem) 22 | for k, v in pairs(elem.attr[3]) do 23 | if k == "file" then 24 | files[v] = elem.text 25 | end 26 | end 27 | return nil 28 | end 29 | 30 | function Pandoc (elem) 31 | local content = {} 32 | for filename, code in pairs(files) do 33 | table.insert(content, pandoc.Str(filename .. "\n")) 34 | end 35 | return pandoc.Pandoc(pandoc.Plain(content)) 36 | end -------------------------------------------------------------------------------- /noodles/workflow/graphs.py: -------------------------------------------------------------------------------- 1 | def find_links_to(links, node): 2 | """Find links to a node. 3 | 4 | :param links: 5 | forward links of a workflow 6 | :type links: Mapping[NodeId, Set[(NodeId, ArgumentType, [int|str]])] 7 | 8 | :param node: 9 | index to a node 10 | :type node: int 11 | 12 | :returns: 13 | dictionary of sources for each argument 14 | :rtype: Mapping[(ArgumentType, [int|str]), NodeId] 15 | """ 16 | return {address: src 17 | for src, (tgt, address) in _all_valid(links) 18 | if tgt == node} 19 | 20 | 21 | def _all_valid(links): 22 | """Iterates over all links, forgetting emtpy registers.""" 23 | for k, v in links.items(): 24 | for i in v: 25 | yield k, i 26 | 27 | 28 | def invert_links(links): 29 | """Inverts the call-graph to get a dependency graph. Possibly slow, 30 | short version. 31 | 32 | :param links: 33 | forward links of a call-graph. 34 | :type links: Mapping[NodeId, Set[(NodeId, ArgumentType, [int|str]])] 35 | 36 | :returns: 37 | inverted graph, giving dependency of jobs. 38 | :rtype: Mapping[NodeId, Mapping[(ArgumentType, [int|str]), NodeId]] 39 | """ 40 | return {node: find_links_to(links, node) for node in links} 41 | -------------------------------------------------------------------------------- /examples/das5.py: -------------------------------------------------------------------------------- 1 | from noodles.run.xenon import ( 2 | Machine, XenonJobConfig, run_xenon) 3 | from noodles import gather_all, schedule 4 | from noodles.tutorial import (add, sub, mul) 5 | import xenon 6 | from pathlib import Path 7 | 8 | 9 | def test_xenon_42_multi(): 10 | A = add(1, 1) 11 | B = sub(3, A) 12 | 13 | multiples = [mul(add(i, B), A) for i in range(6)] 14 | C = schedule(sum)(gather_all(multiples)) 15 | 16 | machine = Machine( 17 | scheduler_adaptor='slurm', 18 | location='ssh://fs0.das5.cs.vu.nl/home/jhidding', 19 | credential=xenon.CertificateCredential( 20 | username='jhidding', 21 | certfile='/home/johannes/.ssh/id_rsa'), 22 | jobs_properties={ 23 | 'xenon.adaptors.schedulers.ssh.strictHostKeyChecking': 'false'} 24 | ) 25 | worker_config = XenonJobConfig( 26 | prefix=Path('/home/jhidding/.local/share/workon/mcfly'), 27 | working_dir='/home/jhidding/', time_out=1000000000000, 28 | verbose=False) # , options=['-C', 'TitanX', '--gres=gpu:1']) 29 | 30 | result = run_xenon( 31 | C, machine=machine, worker_config=worker_config, 32 | n_processes=2) 33 | 34 | print("The answer is:", result) 35 | 36 | 37 | xenon.init() 38 | test_xenon_42_multi() 39 | -------------------------------------------------------------------------------- /noodles/run/messages.py: -------------------------------------------------------------------------------- 1 | """ 2 | Messages to facilitate communication between scheduler and remote workers. 3 | There are currently three types of messages: 4 | 5 | * ``JobMessage``, sending a job to a worker. 6 | * ``ResultMessage``, a worker returning a result. 7 | * ``PilotMessage``, extra communication back and forth, status updates, 8 | performance information, but also stopping a worker in a nice way. 9 | """ 10 | 11 | from ..serial import Reasonable 12 | 13 | 14 | class EndOfWork(object): 15 | pass 16 | 17 | 18 | class JobMessage(Reasonable): 19 | def __init__(self, key, node): 20 | self.key = key 21 | self.node = node 22 | 23 | def __iter__(self): 24 | return iter((self.key, self.node)) 25 | 26 | @property 27 | def hints(self): 28 | return self.node.hints 29 | 30 | 31 | class ResultMessage(Reasonable): 32 | def __init__(self, key, status, value, msg): 33 | self.key = key 34 | self.status = status 35 | self.value = value 36 | self.msg = msg 37 | 38 | def __iter__(self): 39 | return iter((self.key, self.status, self.value, self.msg)) 40 | 41 | 42 | class PilotMessage(Reasonable): 43 | def __init__(self, msg, **kwargs): 44 | self.msg = msg 45 | self.__dict__.update(kwargs) 46 | -------------------------------------------------------------------------------- /test/test_invert_graph.py: -------------------------------------------------------------------------------- 1 | from noodles import schedule 2 | from noodles.workflow import ( 3 | invert_links, get_workflow, is_workflow, Empty, ArgumentAddress, 4 | ArgumentKind, is_node_ready) 5 | 6 | 7 | @schedule 8 | def value(a): 9 | return a 10 | 11 | 12 | @schedule 13 | def add(a, b): 14 | return a+b 15 | 16 | 17 | def test_invert_links(): 18 | A = value(1) 19 | B = value(2) 20 | C = add(A, B) 21 | 22 | C = get_workflow(C) 23 | A = get_workflow(A) 24 | B = get_workflow(B) 25 | 26 | assert is_workflow(C) 27 | assert C.nodes[C.root].bound_args.args == (Empty, Empty) 28 | assert (C.root, ArgumentAddress(ArgumentKind.regular, 'a', None)) \ 29 | in C.links[A.root] 30 | assert (C.root, ArgumentAddress(ArgumentKind.regular, 'b', None)) \ 31 | in C.links[B.root] 32 | 33 | deps = invert_links(C.links) 34 | assert deps == { 35 | A.root: {}, 36 | B.root: {}, 37 | C.root: { 38 | ArgumentAddress(ArgumentKind.regular, 'a', None): A.root, 39 | ArgumentAddress(ArgumentKind.regular, 'b', None): B.root}} 40 | 41 | 42 | def test_is_node_ready(): 43 | A = value(1) 44 | B = add(1, A) 45 | A = get_workflow(A) 46 | B = get_workflow(B) 47 | 48 | assert is_node_ready(A.nodes[A.root]) 49 | assert not is_node_ready(B.nodes[B.root]) 50 | -------------------------------------------------------------------------------- /test/test_broker.py: -------------------------------------------------------------------------------- 1 | import noodles 2 | from noodles.tutorial import (add, sub, mul, accumulate) 3 | from noodles.display import (NCDisplay) 4 | from noodles.run.threading.vanilla import ( 5 | run_parallel) 6 | from noodles.run.runners import (run_parallel_with_display) 7 | from noodles.run.single.vanilla import run_single 8 | 9 | 10 | def test_broker_01(): 11 | A = add(1, 1) 12 | B = sub(3, A) 13 | 14 | multiples = [mul(add(i, B), A) for i in range(6)] 15 | C = accumulate(noodles.gather(*multiples)) 16 | 17 | assert run_parallel(C, 4) == 42 18 | 19 | 20 | def test_broker_02(): 21 | A = add(1, 1) 22 | B = sub(3, A) 23 | 24 | multiples = [mul(add(i, B), A) for i in range(6)] 25 | C = accumulate(noodles.gather(*multiples)) 26 | 27 | assert run_single(C) == 42 28 | 29 | 30 | @noodles.schedule_hint(display="{a} + {b}", confirm=True) 31 | def log_add(a, b): 32 | return a + b 33 | 34 | 35 | @noodles.schedule_hint(display="{msg}") 36 | def message(msg, value=0): 37 | return value() 38 | 39 | 40 | def test_broker_logging(): 41 | A = log_add(1, 1) 42 | B = sub(3, A) 43 | 44 | multiples = [mul(log_add(i, B), A) for i in range(6)] 45 | C = accumulate(noodles.gather(*multiples)) 46 | 47 | with NCDisplay(title="Running the test") as display: 48 | assert run_parallel_with_display(C, 4, display) == 42 49 | -------------------------------------------------------------------------------- /noodles/run/remote/io.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manage IO between remote worker/pilot job, and the scheduler. Here there are 3 | two options: use json, or msgpack. 4 | """ 5 | 6 | from ...lib import coroutine 7 | 8 | 9 | def JSONObjectReader(registry, fi, deref=False): 10 | """Stream objects from a JSON file. 11 | 12 | :param registry: serialisation registry. 13 | :param fi: input file 14 | :param deref: flag, if True, objects will be dereferenced on decoding, 15 | otherwise we are lazy about decoding a JSON string. 16 | """ 17 | for line in fi: 18 | yield registry.from_json(line, deref=deref) 19 | 20 | 21 | @coroutine 22 | def JSONObjectWriter(registry, fo, host=None): 23 | """Sink; writes object as JSON to a file. 24 | 25 | :param registry: serialisation registry. 26 | :param fo: output file. 27 | :param host: name of the host that encodes the JSON. This is relevant if 28 | the encoded data refers to external files for mass storage. 29 | 30 | In normal use, it may occur that the pipe to which we write is broken, 31 | for instance when the remote process shuts down. In that case, this 32 | coroutine exits. 33 | """ 34 | while True: 35 | obj = yield 36 | try: 37 | print(registry.to_json(obj, host=host), file=fo, flush=True) 38 | except BrokenPipeError: 39 | return 40 | -------------------------------------------------------------------------------- /noodles/lib/connection.py: -------------------------------------------------------------------------------- 1 | class Connection(object): 2 | """Combine a source and a sink. These should represent the IO of 3 | some object, probably a worker. In this case the `source` is a 4 | coroutine generating results, while the sink needs to be fed jobs. 5 | 6 | .. |Connection| replace:: :py:class:`Connection` 7 | """ 8 | def __init__(self, source, sink, aux=None): 9 | """Connection constructor 10 | 11 | :param source: 12 | The source signal coroutine 13 | :type source: generator 14 | 15 | :param sink: 16 | The signal sink coroutine 17 | :type sink: sink coroutine 18 | """ 19 | self.source = source 20 | self.sink = sink 21 | self.aux = aux 22 | self.online = True 23 | 24 | def setup(self): 25 | """Activate the source and sink functions and return them in 26 | that order. 27 | 28 | :returns: 29 | source, sink 30 | :rtype: tuple""" 31 | return self.source(), self.sink() 32 | 33 | def __rshift__(self, other): 34 | return self.__join__(other) 35 | 36 | def __join__(self, other): 37 | """A connection has one output channel, so the '>>' operator 38 | connects the source to a coroutine, creating a new connection.""" 39 | return Connection(self.source >> other, self.sink) 40 | -------------------------------------------------------------------------------- /noodles/patterns/control_flow_statements.py: -------------------------------------------------------------------------------- 1 | from noodles import (schedule, quote, unquote) 2 | from typing import Any 3 | 4 | 5 | def conditional( 6 | b: bool, 7 | branch_true: Any, 8 | branch_false: Any = None) -> Any: 9 | """ 10 | Control statement to follow a branch 11 | in workflow. Equivalent to the `if` statement 12 | in standard Python. 13 | 14 | The quote function delay the evaluation of the branches 15 | until the boolean is evaluated. 16 | 17 | :param b: 18 | promised boolean value. 19 | :param branch_true: 20 | statement to execute in case of a true predicate. 21 | :param branch_false: 22 | default operation to execute in case of a false predicate. 23 | :returns: :py:class:`PromisedObject` 24 | """ 25 | return schedule_branches(b, quote(branch_true), quote(branch_false)) 26 | 27 | 28 | @schedule 29 | def schedule_branches(b: bool, quoted_true, quoted_false): 30 | """ 31 | Helper function to choose which workflow to execute 32 | based on the boolean `b`. 33 | 34 | :param b: 35 | promised boolean value 36 | 37 | :param quoted_true: 38 | quoted workflow to eval if the boolean is true. 39 | :param quoted_true: 40 | quoted workflow to eval if the boolean is false. """ 41 | if b: 42 | return unquote(quoted_true) 43 | else: 44 | return unquote(quoted_false) 45 | -------------------------------------------------------------------------------- /noodles/patterns/find_first.py: -------------------------------------------------------------------------------- 1 | from noodles import (schedule, quote, unquote) 2 | 3 | 4 | def find_first(pred, lst): 5 | """Find the first result of a list of promises `lst` that satisfies a 6 | predicate `pred`. 7 | 8 | :param pred: a function of one argument returning `True` or `False`. 9 | :param lst: a list of promises or values. 10 | :return: a promise of a value or `None`. 11 | 12 | This is a wrapper around :func:`s_find_first`. The first item on the list 13 | is passed *as is*, forcing evalutation. The tail of the list is quoted, and 14 | only unquoted if the predicate fails on the result of the first promise. 15 | 16 | If the input list is empty, `None` is returned.""" 17 | if lst: 18 | return s_find_first(pred, lst[0], [quote(l) for l in lst[1:]]) 19 | else: 20 | return None 21 | 22 | 23 | @schedule 24 | def s_find_first(pred, first, lst): 25 | """Evaluate `first`; if predicate `pred` succeeds on the result of `first`, 26 | return the result; otherwise recur on the first element of `lst`. 27 | 28 | :param pred: a predicate. 29 | :param first: a promise. 30 | :param lst: a list of quoted promises. 31 | :return: the first element for which predicate is true.""" 32 | if pred(first): 33 | return first 34 | elif lst: 35 | return s_find_first(pred, unquote(lst[0]), lst[1:]) 36 | else: 37 | return None 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Setup script for Noodles. 5 | """ 6 | 7 | from pathlib import Path 8 | from setuptools import setup, find_packages 9 | 10 | # Get the long description from the README file 11 | here = Path(__file__).parent.absolute() 12 | with (here / 'README.md').open(encoding='utf-8') as f: 13 | long_description = f.read() 14 | 15 | setup( 16 | name='Noodles', 17 | version='0.3.4', 18 | description='Workflow Engine', 19 | long_description=long_description, 20 | long_description_content_type='text/markdown', 21 | author='Johan Hidding', 22 | url='https://github.com/NLeSC/noodles', 23 | packages=find_packages(exclude=['test*']), 24 | classifiers=[ 25 | 'License :: OSI Approved :: Apache Software License', 26 | 'Intended Audience :: Developers', 27 | 'Intended Audience :: Science/Research', 28 | 'Environment :: Console', 29 | 'Development Status :: 4 - Beta', 30 | 'Programming Language :: Python :: 3.7', 31 | 'Topic :: System :: Distributed Computing'], 32 | 33 | install_requires=['graphviz', 'ujson'], 34 | extras_require={ 35 | 'xenon': ['pyxenon'], 36 | 'numpy': ['numpy', 'h5py', 'filelock'], 37 | 'develop': [ 38 | 'pytest', 'pytest', 'coverage', 'pep8', 'numpy', 'tox', 39 | 'sphinx', 'sphinx_rtd_theme', 'nbsphinx', 'flake8'], 40 | }, 41 | ) 42 | -------------------------------------------------------------------------------- /noodles/display/pretty_term.py: -------------------------------------------------------------------------------- 1 | 2 | term_codes = { 3 | 'fg': (38, ';2;{0};{1};{2}'.format, 'm'), 4 | 'bg': (48, ';2;{0};{1};{2}'.format, 'm'), 5 | 'bold': (1, 'm'), 6 | 'underline': (4, 'm'), 7 | 'regular': (23, 'm'), 8 | 'italic': (3, 'm'), 9 | 'reset': (0, 'm'), 10 | 'back': (lambda n=1: str(n), 'D'), 11 | 'forward': (lambda n=1: str(n), 'C'), 12 | 'newline': ('1E',), 13 | 'up': ('{0}'.format, 'A'), 14 | 'down': ('{0}'.format, 'B'), 15 | 'save': ('s',), 16 | 'restore': ('u',), 17 | 'move': ('{0};{1}'.format, 'H'), 18 | 'clear': ('2J',), 19 | 'reverse': ('7m',)} 20 | 21 | 22 | def make_escape(cmd, *args): 23 | def f(x): 24 | if hasattr(x, '__call__'): 25 | return x(*args) 26 | else: 27 | return str(x) 28 | 29 | return '\033[' + ''.join(map(f, term_codes[cmd])) 30 | 31 | 32 | class OutStream: 33 | def __init__(self, f): 34 | self.f = f 35 | 36 | def __lshift__(self, x): 37 | """Emulate the much liked C++ syntax for chaining output.""" 38 | if isinstance(x, list): 39 | self.f.write(make_escape(*x)) 40 | self.f.flush() 41 | return self 42 | 43 | if isinstance(x, str): 44 | self.f.write(x) 45 | self.f.flush() 46 | return self 47 | 48 | raise TypeError 49 | -------------------------------------------------------------------------------- /test/workflows/patterns.py: -------------------------------------------------------------------------------- 1 | from .workflow_factory import workflow_factory 2 | from noodles import ( 3 | patterns, schedule) 4 | import math 5 | import numpy as np 6 | 7 | 8 | @workflow_factory(result=True, requires=['local', 'nocache']) 9 | def fold_and_map(): 10 | arr = np.random.normal(size=100) 11 | xs = patterns.fold(aux_sum, 0, arr) 12 | ys = patterns.map(lambda x: x ** 2, xs) 13 | 14 | return schedule(np.allclose)(ys, np.cumsum(arr) ** 2) 15 | 16 | 17 | @workflow_factory(result=True, requires=['local', 'nocache']) 18 | def map_filter_and_all(): 19 | arr = np.random.normal(size=100) 20 | xs = patterns.map(lambda x: abs(x), arr) 21 | rs = patterns.filter(lambda x: x < 0.5, xs) 22 | 23 | return patterns.all(lambda x: x < 0.5, rs) 24 | 25 | 26 | @workflow_factory(result=True, requires=['local', 'nocache']) 27 | def zip_with(): 28 | arr = np.random.normal(size=100) 29 | ys = patterns.map(lambda x: np.pi * x, arr) 30 | rs = patterns.zip_with(x_sin_pix, arr, ys) 31 | 32 | return schedule(np.allclose)(rs, arr * np.sin(np.pi * arr)) 33 | 34 | 35 | @workflow_factory(result=True, requires=['local', 'nocache']) 36 | def map_any(): 37 | arr = np.pi * np.arange(10., dtype=np.float64) 38 | xs = patterns.map(math.cos, arr) 39 | 40 | return patterns.any(lambda x: x < 0, xs) 41 | 42 | 43 | def x_sin_pix(x, y): 44 | return x * math.sin(y) 45 | 46 | 47 | def aux_sum(acc, x): 48 | r = acc + x 49 | return r, r 50 | -------------------------------------------------------------------------------- /test/test_sqlite.py: -------------------------------------------------------------------------------- 1 | # import pytest 2 | 3 | from noodles.prov.sqlite import JobDB 4 | from noodles import serial 5 | from noodles.tutorial import (sub, add) 6 | from noodles.run.scheduler import Job 7 | from noodles.run.messages import (ResultMessage) 8 | 9 | 10 | def test_add_job(): 11 | db = JobDB(':memory:', registry=serial.base) 12 | 13 | wf = sub(1, 1) 14 | job = Job(wf._workflow, wf._workflow.root) 15 | key, node = db.register(job) 16 | msg, value = db.add_job_to_db(key, node) 17 | assert msg == 'initialized' 18 | 19 | duplicates = db.store_result_in_db(ResultMessage(key, 'done', 0, None)) 20 | assert duplicates == () 21 | 22 | key, node = db.register(job) 23 | msg, result = db.add_job_to_db(key, node) 24 | assert msg == 'retrieved' 25 | assert result.value == 0 26 | 27 | 28 | def test_attaching(): 29 | db = JobDB(':memory:', registry=serial.base) 30 | 31 | wf = add(1, 1) 32 | job = Job(wf._workflow, wf._workflow.root) 33 | key1, node1 = db.register(job) 34 | msg, value = db.add_job_to_db(key1, node1) 35 | assert msg == 'initialized' 36 | 37 | key2, node2 = db.register(job) 38 | msg, value = db.add_job_to_db(key2, node2) 39 | assert msg == 'attached' 40 | 41 | duplicates = db.store_result_in_db(ResultMessage(key1, 'done', 2, None)) 42 | assert duplicates == (key2,) 43 | 44 | key3, node3 = db.register(job) 45 | msg, result = db.add_job_to_db(key3, node3) 46 | assert msg == 'retrieved' 47 | assert result.value == 2 48 | -------------------------------------------------------------------------------- /examples/boil/test/test.cc: -------------------------------------------------------------------------------- 1 | #include "test.hh" 2 | #include 3 | #include 4 | 5 | int __assert_fail(std::string const &expr, std::string const &file, int lineno) 6 | { 7 | std::ostringstream oss; 8 | oss << file << ":" << lineno << ": Assertion failed: " << expr; 9 | throw oss.str(); 10 | return 0; 11 | } 12 | 13 | std::unique_ptr> Test::_instances; 14 | 15 | Test::imap &Test::instances() { 16 | if (not _instances) 17 | _instances = std::unique_ptr(new imap); 18 | 19 | return *_instances; 20 | } 21 | 22 | bool run_test(Test const *unit) { 23 | bool pass; 24 | try { 25 | pass = (*unit)(); 26 | } 27 | catch (char const *e) { 28 | pass = false; 29 | std::cerr << "failure: " << e << std::endl; 30 | } 31 | catch (std::string const &e) { 32 | pass = false; 33 | std::cerr << "failure: " << e << std::endl; 34 | } 35 | 36 | if (pass) { 37 | std::cerr << "\033[62G[\033[32mpassed\033[m]\n"; 38 | } 39 | else { 40 | std::cerr << "\033[62G[\033[31mfailed\033[m]\n"; 41 | } 42 | 43 | return pass; 44 | } 45 | 46 | void Test::all(bool should_break) { 47 | for (auto &kv : instances()) { 48 | std::cerr << "[test \033[34;1m" << kv.first << "\033[m]\n"; 49 | if (not run_test(kv.second) and should_break) 50 | break; 51 | } 52 | } 53 | 54 | Test::Test(std::string const &name_, std::function const &code_): 55 | _name(name_), code(code_) { 56 | instances()[_name] = this; 57 | } 58 | 59 | Test::~Test() { 60 | instances().erase(_name); 61 | } 62 | -------------------------------------------------------------------------------- /test/serial/test_numpy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | try: 4 | import numpy as np 5 | from numpy import (random, fft, exp) 6 | from noodles.serial.numpy import arrays_to_hdf5 7 | 8 | from noodles.run.threading.sqlite3 import ( 9 | run_parallel 10 | ) 11 | 12 | except ImportError: 13 | has_numpy = False 14 | else: 15 | has_numpy = True 16 | 17 | from noodles import schedule, serial 18 | 19 | 20 | def registry(): 21 | return serial.base() + arrays_to_hdf5() 22 | 23 | 24 | @schedule(display="fft", confirm=True, store=True) 25 | def do_fft(a): 26 | return fft.fft(a) 27 | 28 | 29 | @schedule 30 | def make_kernel(n, sigma): 31 | return exp(-fft.fftfreq(n)**2 * sigma**2) 32 | 33 | 34 | @schedule(display="ifft", confirm=True, store=True) 35 | def do_ifft(a): 36 | return fft.ifft(a).real 37 | 38 | 39 | @schedule 40 | def apply_filter(a, b): 41 | return a * b 42 | 43 | 44 | @schedule(display="make noise {seed}", confirm=True, store=True) 45 | def make_noise(n, seed=0): 46 | random.seed(seed) 47 | return random.normal(0, 1, n) 48 | 49 | 50 | def run(wf): 51 | result = run_parallel( 52 | wf, n_threads=2, registry=registry, db_file=':memory:', 53 | always_cache=False) 54 | return result 55 | 56 | 57 | @pytest.mark.skipif(not has_numpy, reason="NumPy needed.") 58 | def test_hdf5(): 59 | x = make_noise(256) 60 | k = make_kernel(256, 10) 61 | x_smooth = do_ifft(apply_filter(do_fft(x), k)) 62 | result = run(x_smooth) 63 | 64 | assert isinstance(result, np.ndarray) 65 | assert result.size == 256 66 | -------------------------------------------------------------------------------- /test/backends/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines backends for the test matrix. 3 | """ 4 | 5 | from noodles import run_single 6 | from noodles import run_parallel 7 | from noodles import run_process, serial 8 | from noodles.run.single.sqlite3 import run_single as run_single_sqlite 9 | from noodles.serial.numpy import arrays_to_string 10 | from noodles.run.threading.sqlite3 import run_parallel as run_parallel_sqlite 11 | from .backend_factory import backend_factory 12 | 13 | 14 | def registry(): 15 | """Serialisation registry for matrix testing backends.""" 16 | return serial.pickle() + serial.base() + arrays_to_string() 17 | 18 | 19 | backends = { 20 | 'single': backend_factory( 21 | run_single, supports=['local', 'nocache']), 22 | 'single-sqlite': backend_factory( 23 | run_single_sqlite, supports=['local', 'prov'], 24 | db_file=':memory:', registry=registry, always_cache=True), 25 | 'threads-4': backend_factory( 26 | run_parallel, supports=['local', 'nocache'], n_threads=4), 27 | 'threads-4-sqlite': backend_factory( 28 | run_parallel_sqlite, supports=['local', 'prov'], 29 | n_threads=4, db_file=':memory:', registry=registry, always_cache=True), 30 | 'threads-4-sqlite-optional': backend_factory( 31 | run_parallel_sqlite, supports=['local', 'prov'], 32 | n_threads=4, db_file=':memory:', registry=registry, 33 | always_cache=False), 34 | 'processes-2': backend_factory( 35 | run_process, supports=['remote'], n_processes=2, registry=registry, 36 | verbose=True) 37 | } 38 | 39 | __all__ = ['backends'] 40 | -------------------------------------------------------------------------------- /test/workflows/setters.py: -------------------------------------------------------------------------------- 1 | import noodles 2 | from noodles.tutorial import accumulate 3 | from .workflow_factory import workflow_factory 4 | import sys 5 | 6 | 7 | class my_dict(dict): 8 | def values(self): 9 | return list(super(my_dict, self).values()) 10 | 11 | 12 | @noodles.schedule 13 | def word_length(x): 14 | return len(x) 15 | 16 | 17 | @workflow_factory( 18 | result={'apple': 5, 'orange': 6, 'kiwi': 4}) 19 | def set_item(): 20 | word_lengths = noodles.delay({}) 21 | 22 | for word in ['apple', 'orange', 'kiwi']: 23 | word_lengths[word] = word_length(word) 24 | 25 | return word_lengths 26 | 27 | 28 | @workflow_factory( 29 | result=15) 30 | def methods_on_promises(): 31 | word_lengths = noodles.delay(my_dict()) 32 | 33 | for word in ['apple', 'orange', 'kiwi']: 34 | word_lengths[word] = word_length(word) 35 | 36 | return accumulate(word_lengths.values()) 37 | 38 | 39 | class uncopyable_dict(dict): 40 | def __init__(self): 41 | super(uncopyable_dict, self).__init__() 42 | 43 | # trying to copy sys.stdout will raise an error 44 | self._uncopyable = sys.stdout 45 | 46 | 47 | @workflow_factory( 48 | raises=TypeError) 49 | def set_item_error(): 50 | word_lengths = noodles.delay(uncopyable_dict) 51 | 52 | for word in ['apple', 'orange', 'kiwi']: 53 | word_lengths[word] = word_length(word) 54 | 55 | return word_lengths 56 | 57 | 58 | @workflow_factory( 59 | raises=TypeError) 60 | def set_attr_error(): 61 | obj = noodles.delay(uncopyable_dict) 62 | obj.attr = 42 63 | return obj 64 | -------------------------------------------------------------------------------- /.zenodo.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Task-based parallel programming model in Python. Run complex workflows on large computer clusters or parallelize codes on your laptop: Noodles offers the same intuitive interface.", 3 | "license": "Apache-2.0", 4 | "title": "Noodles", 5 | "upload_type": "software", 6 | "creators": [{ 7 | "affiliation": "Netherlands eScience Center", 8 | "name": "Hidding, Johan" 9 | }, 10 | { 11 | "affiliation": "Netherlands eScience Center", 12 | "name": "van Hees, Vincent" 13 | }, 14 | { 15 | "affiliation": "Netherlands eScience Center", 16 | "name": "Spreeuw, Hanno" 17 | }, 18 | { 19 | "affiliation": "Netherlands eScience Center", 20 | "name": "Zapata, Felipe" 21 | }, 22 | { 23 | "affiliation": "Netherlands eScience Center", 24 | "name": "Weel, Berend" 25 | }, 26 | { 27 | "affiliation": "Netherlands eScience Center", 28 | "name": "Borgdorff, Joris" 29 | }, 30 | { 31 | "affiliation": "Netherlands eScience Center", 32 | "name": "Ridder, Lars" 33 | }, 34 | { 35 | "affiliation": "Netherlands eScience Center", 36 | "name": "van Werkhoven, Ben" 37 | }, 38 | { 39 | "affiliation": "Netherlands eScience Center", 40 | "name": "Kuzniar, Arnold", 41 | "orcid": "0000-0003-1711-7961" 42 | } 43 | ], 44 | "access_right": "open", 45 | "keywords": [ 46 | "parallel programming", 47 | "functional programming", 48 | "Python" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /examples/soba/subcommands.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "task": "0", 4 | "exclude": [1,2,3], 5 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=0" 6 | }, 7 | { 8 | "task": "1", 9 | "exclude": [0,2,3,4], 10 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=1" 11 | }, 12 | { 13 | "task": "2", 14 | "exclude": [0,1,3,4,5], 15 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=2" 16 | }, 17 | { 18 | "task": "3", 19 | "exclude": [0,1,2,4,5,6], 20 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=3" 21 | }, 22 | { 23 | "task": "4", 24 | "exclude": [1,2,3,5,6,7], 25 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=4" 26 | }, 27 | { 28 | "task": "5", 29 | "exclude": [2,3,4,6,7,8], 30 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=5" 31 | }, 32 | { 33 | "task": "6", 34 | "exclude": [3,4,5,7,8,9], 35 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=6" 36 | }, 37 | { 38 | "task": "7", 39 | "exclude": [4,5,6,8,9], 40 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=7" 41 | }, 42 | { 43 | "task": "8", 44 | "exclude": [5,6,7,9], 45 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=8" 46 | }, 47 | { 48 | "task": "9", 49 | "exclude": [6,7,8], 50 | "command": "echo mm3d TestOscar .*JPG ExpTxt=1 Desc=0 ExpSubCom=1 SubcommandIndex=9" 51 | } 52 | ] 53 | -------------------------------------------------------------------------------- /notebooks/first_steps-workflow-b.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 139745633820232 14 | 15 | mul 16 | (—, 2) 17 | 18 | 19 | 20 | 139745624620896 21 | 22 | add 23 | (1, 1) 24 | 25 | 26 | 27 | 139745624620896->139745633820232 28 | 29 | 30 | x 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /noodles/run/xenon/runner.py: -------------------------------------------------------------------------------- 1 | from .dynamic_pool import DynamicPool, xenon_interactive_worker 2 | from ..scheduler import Scheduler 3 | from ..job_keeper import JobKeeper 4 | 5 | from ...workflow import (get_workflow) 6 | 7 | from copy import copy 8 | 9 | 10 | def run_xenon_simple(workflow, machine, worker_config): 11 | """Run a workflow using a single Xenon remote worker. 12 | 13 | :param workflow: |Workflow| or |PromisedObject| to evaluate. 14 | :param machine: |Machine| instance. 15 | :param worker_config: Configuration for the pilot job.""" 16 | scheduler = Scheduler() 17 | 18 | return scheduler.run( 19 | xenon_interactive_worker(machine, worker_config), 20 | get_workflow(workflow) 21 | ) 22 | 23 | 24 | def run_xenon( 25 | workflow, *, machine, worker_config, n_processes, deref=False, 26 | verbose=False): 27 | """Run the workflow using a number of online Xenon workers. 28 | 29 | :param workflow: |Workflow| or |PromisedObject| to evaluate. 30 | :param machine: The |Machine| instance. 31 | :param worker_config: Configuration of the pilot job 32 | :param n_processes: Number of pilot jobs to start. 33 | :param deref: Set this to True to pass the result through one more 34 | encoding and decoding step with object dereferencing turned on. 35 | :returns: the result of evaluating the workflow 36 | """ 37 | 38 | dynamic_pool = DynamicPool(machine) 39 | 40 | for i in range(n_processes): 41 | cfg = copy(worker_config) 42 | cfg.name = 'xenon-{0:02}'.format(i) 43 | dynamic_pool.add_xenon_worker(cfg) 44 | 45 | job_keeper = JobKeeper() 46 | S = Scheduler(job_keeper=job_keeper, verbose=verbose) 47 | 48 | result = S.run( 49 | dynamic_pool, get_workflow(workflow) 50 | ) 51 | 52 | dynamic_pool.close_all() 53 | 54 | if deref: 55 | return worker_config.registry().dereference(result, host='scheduler') 56 | else: 57 | return result 58 | -------------------------------------------------------------------------------- /notebooks/control-factorial-one.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 139814310275504 14 | 15 | mul 16 | (—, 10, '<memory gobble>') 17 | 18 | 19 | 20 | 139814310274048 21 | 22 | factorial 23 | (9, '<memory gobble>') 24 | 25 | 26 | 27 | 139814310274048->139814310275504 28 | 29 | 30 | x 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /notebooks/control-tail-recursive-factorial.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 139814310275224 14 | 15 | factorial_tr 16 | (9, —, '<memory gobble>') 17 | 18 | 19 | 20 | 139814310273544 21 | 22 | mul 23 | (1, 10, '<memory gobble>') 24 | 25 | 26 | 27 | 139814310273544->139814310275224 28 | 29 | 30 | acc 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Noodles - parallel programming in Python 3 | --- 4 | 5 | [![Travis](https://travis-ci.org/NLeSC/noodles.svg?branch=master)](https://travis-ci.org/NLeSC/noodles) 6 | [![Zenodo DOI](https://zenodo.org/badge/45391130.svg)](https://zenodo.org/badge/latestdoi/45391130) 7 | [![Code coverage](https://codecov.io/gh/NLeSC/noodles/branch/master/graph/badge.svg)](https://codecov.io/gh/NLeSC/noodles) 8 | [![Documentation](https://readthedocs.org/projects/noodles/badge/?version=latest)](https://noodles.readthedocs.io/en/latest/?badge=latest) 9 | 10 | ::: {.splash} 11 | * Write readable code 12 | * Parallelise with a dash of Noodle sauce! 13 | * Scale your applications from laptop to HPC using Xenon 14 | + [Learn more about Xenon](https://xenon-middleware.github.io/xenon) 15 | * Read our [documentation](https://noodles.rtfd.io/), including tutorials on: 16 | + [Creating parallel programs](https://noodles.readthedocs.io/en/latest/poetry_tutorial.html) 17 | + [Circumventing the global interpreter lock](https://noodles.readthedocs.io/en/latest/prime_numbers.html) 18 | + [Handling errors in a meaningful way](https://noodles.readthedocs.io/en/latest/errors.html) 19 | + [Serialising your data](https://noodles.readthedocs.io/en/latest/serialisation.html) 20 | + [Functional programming and flow control](https://noodles.readthedocs.io/en/latest/control_your_flow.html) 21 | ::: 22 | 23 | # What is Noodles? 24 | 25 | Noodles is a task-based parallel programming model in Python that offers the same intuitive interface when running complex workflows on your laptop or on large computer clusters. 26 | 27 | # Installation 28 | To install the latest version from PyPI: 29 | 30 | ``` 31 | pip install noodles 32 | ``` 33 | 34 | To enable the Xenon backend for remote job execution, 35 | 36 | ``` 37 | pip install noodles[xenon] 38 | ``` 39 | 40 | This requires a Java Runtime to be installed, you may check this by running 41 | 42 | ``` 43 | java --version 44 | ``` 45 | 46 | which should print the version of the currently installed JRE. 47 | 48 | 49 | # Documentation 50 | All the latest documentation is available on [Read the Docs](https://noodles.rtfd.io/). 51 | 52 | -------------------------------------------------------------------------------- /noodles/draw_workflow/draw_workflow.py: -------------------------------------------------------------------------------- 1 | from pygraphviz import AGraph 2 | from inspect import Parameter 3 | 4 | 5 | def _sugar(s): 6 | s = s.replace("{", "{{").replace("}", "}}") 7 | if len(s) > 50: 8 | return s[:20] + " ... " + s[-20:] 9 | else: 10 | return s 11 | 12 | 13 | def _format_arg_list(a, v): 14 | if len(a) == 0: 15 | if v: 16 | return "(\u2026)" 17 | else: 18 | return "()" 19 | 20 | s = "({0}{1})" 21 | for i in a[:-1]: 22 | if isinstance(i, float): 23 | istr = "{:.6}".format(i) 24 | else: 25 | istr = str(i) 26 | 27 | s = s.format( 28 | _sugar(istr) 29 | if i is not Parameter.empty 30 | else "\u2014", ", {0}{1}") 31 | 32 | if v: 33 | return s.format("\u2026", "") 34 | 35 | if isinstance(a[-1], float): 36 | istr = "{:.6}".format(a[-1]) 37 | else: 38 | istr = str(a[-1]) 39 | 40 | return s.format( 41 | _sugar(istr) 42 | if a[-1] is not Parameter.empty 43 | else "\u2014", "") 44 | 45 | 46 | def draw_workflow(filename, workflow, paint=None): 47 | dot = AGraph(directed=True) # (comment="Computing scheme") 48 | dot.node_attr['style'] = 'filled' 49 | for i, n in workflow.nodes.items(): 50 | dot.add_node(i, label="{0} \n {1}".format( 51 | n.foo.__name__, _format_arg_list(n.bound_args.args, None))) 52 | x = dot.get_node(i) 53 | if paint: 54 | paint(x, n.foo.__name__) 55 | 56 | for i in workflow.links: 57 | for j in workflow.links[i]: 58 | dot.add_edge(i, j[0]) 59 | dot.layout(prog='dot') 60 | 61 | dot.draw(filename) 62 | 63 | 64 | def graph(workflow): 65 | dot = AGraph(directed=True) # (comment="Computing scheme") 66 | for i, n in workflow.nodes.items(): 67 | dot.add_node(i, label="{0} \n {1}".format( 68 | n.foo.__name__, _format_arg_list(n.bound_args.args, None))) 69 | 70 | for i in workflow.links: 71 | for j in workflow.links[i]: 72 | dot.add_edge(i, j[0]) 73 | dot.layout(prog='dot') 74 | return dot 75 | -------------------------------------------------------------------------------- /examples/boil/src/render.cc: -------------------------------------------------------------------------------- 1 | #include "types.hh" 2 | #include 3 | 4 | void render(predicate pred, complex a, complex b, unsigned width) { 5 | unsigned height = width/3; 6 | double scale_real = (b.real() - a.real()) / width; 7 | double scale_imag = (b.imag() - a.imag()) / height; 8 | 9 | for (unsigned j = 0; j < height; ++j) { 10 | for (unsigned i = 0; i < width; ++i) { 11 | complex c = a + complex(i * scale_real, j * scale_imag); 12 | 13 | if (pred(c)) 14 | std::cout << '#'; 15 | else 16 | std::cout << ' '; 17 | } 18 | std::cout << std::endl; 19 | } 20 | } 21 | 22 | struct Colour { 23 | int r, g, b; 24 | 25 | Colour(int r_, int g_, int b_): 26 | r(r_), g(g_), b(b_) {} 27 | }; 28 | 29 | Colour colour_map(double x) { 30 | double r = (0.472-0.567*x+4.05*pow(x, 2)) 31 | /(1.+8.72*x-19.17*pow(x, 2)+14.1*pow(x, 3)), 32 | g = 0.108932-1.22635*x+27.284*pow(x, 2)-98.577*pow(x, 3) 33 | +163.3*pow(x, 4)-131.395*pow(x, 5)+40.634*pow(x, 6), 34 | b = 1./(1.97+3.54*x-68.5*pow(x, 2)+243*pow(x, 3) 35 | -297*pow(x, 4)+125*pow(x, 5)); 36 | 37 | return Colour(int(r*255), int(g*255), int(b*255)); 38 | } 39 | 40 | void render_double_colour(unit_map f, complex a, complex b, unsigned width) { 41 | unsigned height = (width * 10) / 16; 42 | double scale_real = (b.real() - a.real()) / width; 43 | double scale_imag = (b.imag() - a.imag()) / height; 44 | 45 | for (unsigned j = 0; j < height; j += 2) { 46 | for (unsigned i = 0; i < width; ++i) { 47 | complex c1 = a + complex(i * scale_real, j * scale_imag); 48 | complex c2 = a + complex(i * scale_real, (j+1) * scale_imag); 49 | auto clr1 = colour_map(f(c1)), 50 | clr2 = colour_map(f(c2)); 51 | 52 | std::cout << "\033[38;2;" << clr1.r << ";" 53 | << clr1.g << ";" << clr1.b << "m" 54 | << "\033[48;2;" << clr2.r << ";" 55 | << clr2.g << ";" << clr2.b << "m▀"; 56 | } 57 | std::cout << "\033[m\n"; 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /noodles/run/single/sqlite3.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements single-threaded worker with Sqlite3 support. 3 | """ 4 | 5 | from ..scheduler import (Scheduler) 6 | from ..messages import (ResultMessage) 7 | from ..worker import (run_job) 8 | from ..logging import make_logger 9 | 10 | from ...workflow import (get_workflow) 11 | from ...prov.sqlite import (JobDB) 12 | from ...lib import (Queue, pull, pull_map, push_map, Connection) 13 | 14 | 15 | def run_single(workflow, *, registry, db_file, always_cache=True): 16 | """"Run workflow in a single thread, storing results in a Sqlite3 17 | database. 18 | 19 | :param workflow: Workflow or PromisedObject to be evaluated. 20 | :param registry: serialization Registry function. 21 | :param db_file: filename of Sqlite3 database, give `':memory:'` to 22 | keep the database in memory only. 23 | :param always_cache: Currently ignored. always_cache is true. 24 | :return: Evaluated result. 25 | """ 26 | with JobDB(db_file, registry) as db: 27 | job_logger = make_logger("worker", push_map, db) 28 | result_logger = make_logger("worker", pull_map, db) 29 | 30 | @pull 31 | def pass_job(source): 32 | """Receives jobs from source, passes back results.""" 33 | for msg in source(): 34 | key, job = msg 35 | status, retrieved_result = db.add_job_to_db(key, job) 36 | 37 | if status == 'retrieved': 38 | yield retrieved_result 39 | continue 40 | 41 | elif status == 'attached': 42 | continue 43 | 44 | result = run_job(key, job) 45 | attached = db.store_result_in_db(result, always_cache=True) 46 | 47 | yield result 48 | yield from (ResultMessage(key, 'attached', result.value, None) 49 | for key in attached) 50 | 51 | scheduler = Scheduler(job_keeper=db) 52 | queue = Queue() 53 | job_front_end = job_logger >> queue.sink 54 | result_front_end = queue.source >> pass_job >> result_logger 55 | single_worker = Connection(result_front_end, job_front_end) 56 | 57 | return scheduler.run(single_worker, get_workflow(workflow)) 58 | -------------------------------------------------------------------------------- /examples/boil/main/main.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "../src/common.hh" 7 | #include "../src/read.hh" 8 | 9 | int main(int argc_, char **argv_) { 10 | std::vector argv(argv_, argv_ + argc_); 11 | 12 | int columns = 80; 13 | bool use_colour = false; 14 | int opt; 15 | if (argv.size() >= 2 and argv[1] == "mandelbrot") { 16 | while ((opt = getopt(argc_, argv_, "cw:")) != -1) { 17 | switch (opt) { 18 | case 'c': use_colour = true; break; 19 | case 'w': columns = read(optarg); 20 | } 21 | } 22 | 23 | if (use_colour) { 24 | render_double_colour(mandelbrot_c(256), 25 | complex(-2.15, -1), complex(0.85, 1), 26 | columns); 27 | } else { 28 | render(mandelbrot(256), 29 | complex(-2, -1), complex(1, 1), 30 | columns); 31 | } 32 | exit(0); 33 | } 34 | 35 | if (argv.size() >= 4 and argv[1] == "julia") { 36 | complex c(read(argv[2]), read(argv[3])); 37 | while ((opt = getopt(argc_ - 3, argv_ + 3, "cw:")) != -1) { 38 | switch (opt) { 39 | case 'c': use_colour = true; break; 40 | case 'w': columns = read(optarg); 41 | } 42 | } 43 | 44 | if (use_colour) { 45 | render_double_colour(julia_c(c, 256), 46 | complex(-2, -1.2), complex(2, 1.2), 47 | columns); 48 | } else { 49 | render(julia(c, 256), 50 | complex(-2, -1.2), complex(2, 1.2), 51 | columns); 52 | } 53 | exit(0); 54 | } 55 | 56 | std::cout << "Toy fractal renderer." << std::endl; 57 | std::cout << "usage: " << argv[0] << " mandelbrot [-c] [-w ] | julia [-c] [-w ]\n\n"; 58 | std::cout << "Some nice coordinates for the Julia set: \n" 59 | << " 0.26, -1, -0.123+0.745i, -1+0.29i, -0.03+0.7i, etc. \n" 60 | << "Pro tip: set your terminal to fullscreen and tiny font, \n" 61 | << " then run with '-w $COLUMNS'.\n"; 62 | std::cout << "The '-c' option adds colour; have fun!" << std::endl; 63 | } 64 | 65 | -------------------------------------------------------------------------------- /noodles/lib/thread_pool.py: -------------------------------------------------------------------------------- 1 | from .streams import (pull, patch) 2 | from .queue import (Queue, EndOfQueue) 3 | import threading 4 | import functools 5 | 6 | 7 | def thread_counter(finalize): 8 | """Modifies a thread target function, such that the number of active 9 | threads is counted. If the count reaches zero, a finalizer is called.""" 10 | n_threads = 0 11 | lock = threading.Lock() 12 | 13 | def target_modifier(target): 14 | @functools.wraps(target) 15 | def modified_target(*args, **kwargs): 16 | nonlocal n_threads, lock 17 | 18 | with lock: 19 | n_threads += 1 20 | 21 | return_value = target(*args, **kwargs) 22 | 23 | with lock: 24 | n_threads -= 1 25 | if n_threads == 0: 26 | finalize() 27 | 28 | return return_value 29 | 30 | return modified_target 31 | 32 | return target_modifier 33 | 34 | 35 | def thread_pool(*workers, results=None, end_of_queue=EndOfQueue): 36 | """Returns a |pull| object, call it ``r``, starting a thread for each given 37 | worker. Each thread pulls from the source that ``r`` is connected to, and 38 | the returned results are pushed to a |Queue|. ``r`` yields from the other 39 | end of the same |Queue|. 40 | 41 | The target function for each thread is |patch|, which can be stopped by 42 | exhausting the source. 43 | 44 | If all threads have ended, the result queue receives end-of-queue. 45 | 46 | :param results: If results should go somewhere else than a newly 47 | constructed |Queue|, a different |Connection| object can be given. 48 | :type results: |Connection| 49 | 50 | :param end_of_queue: end-of-queue signal object passed on to the creation 51 | of the |Queue| object. 52 | 53 | :rtype: |pull| 54 | """ 55 | if results is None: 56 | results = Queue(end_of_queue=end_of_queue) 57 | 58 | count = thread_counter(results.close) 59 | 60 | @pull 61 | def thread_pool_results(source): 62 | for worker in workers: 63 | t = threading.Thread( 64 | target=count(patch), 65 | args=(pull(source) >> worker, results.sink), 66 | daemon=True) 67 | t.start() 68 | 69 | yield from results.source() 70 | 71 | return thread_pool_results 72 | -------------------------------------------------------------------------------- /noodles/run/runners.py: -------------------------------------------------------------------------------- 1 | from .worker import (worker) 2 | from .job_keeper import (JobTimer) 3 | from .scheduler import (Scheduler) 4 | 5 | from ..workflow import (get_workflow) 6 | from ..lib import ( 7 | Queue, push_map, sink_map, branch, patch, 8 | thread_pool) 9 | 10 | from itertools import (repeat) 11 | import threading 12 | 13 | 14 | @push_map 15 | def log_job_start(job): 16 | return job.key, 'start', job.node, None 17 | 18 | 19 | @push_map 20 | def log_job_schedule(job): 21 | return job.key, 'schedule', job.node, None 22 | 23 | 24 | def run_parallel_timing(wf, n, timing_file): 25 | LogQ = Queue() 26 | 27 | with JobTimer(timing_file) as J: 28 | S = Scheduler(job_keeper=J) 29 | threading.Thread( 30 | target=patch, 31 | args=(LogQ.source, J.message), 32 | daemon=True).start() 33 | 34 | W = Queue() \ 35 | >> branch(log_job_start >> LogQ.sink) \ 36 | >> thread_pool(*repeat(worker, n)) \ 37 | >> branch(LogQ.sink) 38 | 39 | result = S.run(W, get_workflow(wf)) 40 | LogQ.wait() 41 | return result 42 | 43 | 44 | def run_single_with_display(wf, display): 45 | """Adds a display to the single runner. Everything still runs in a single 46 | thread. Every time a job is pulled by the worker, a message goes to the 47 | display routine; when the job is finished the result is sent to the display 48 | routine.""" 49 | S = Scheduler(error_handler=display.error_handler) 50 | W = Queue() \ 51 | >> branch(log_job_start.to(sink_map(display))) \ 52 | >> worker \ 53 | >> branch(sink_map(display)) 54 | 55 | return S.run(W, get_workflow(wf)) 56 | 57 | 58 | def run_parallel_with_display(wf, n_threads, display): 59 | """Adds a display to the parallel runner. Because messages come in 60 | asynchronously now, we start an extra thread just for the display 61 | routine.""" 62 | LogQ = Queue() 63 | 64 | S = Scheduler(error_handler=display.error_handler) 65 | 66 | threading.Thread( 67 | target=patch, 68 | args=(LogQ.source, sink_map(display)), 69 | daemon=True).start() 70 | 71 | W = Queue() \ 72 | >> branch(log_job_start >> LogQ.sink) \ 73 | >> thread_pool(*repeat(worker, n_threads)) \ 74 | >> branch(LogQ.sink) 75 | 76 | result = S.run(W, get_workflow(wf)) 77 | LogQ.wait() 78 | 79 | return result 80 | -------------------------------------------------------------------------------- /noodles/run/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides logging facilities that can be inserted at any place in a system 3 | of streams. 4 | """ 5 | 6 | import logging 7 | 8 | from ..lib import (EndOfQueue) 9 | from ..workflow import (is_workflow) 10 | from .messages import (JobMessage, ResultMessage) 11 | 12 | 13 | def _sugar(s): 14 | """Shorten strings that are too long for decency.""" 15 | # s = s.replace("{", "{{").replace("}", "}}") 16 | if len(s) > 50: 17 | return s[:20] + " ... " + s[-20:] 18 | else: 19 | return s 20 | 21 | 22 | def make_logger(name, stream_type, jobs): 23 | """Create a logger component. 24 | 25 | :param name: name of logger child, i.e. logger will be named 26 | `noodles.`. 27 | :type name: str 28 | :param stream_type: type of the stream that this logger will 29 | be inserted into, should be |pull_map| or |push_map|. 30 | :type stream_type: function 31 | :param jobs: job-keeper instance. 32 | :type jobs: dict, |JobKeeper| or |JobDB|. 33 | 34 | :return: a stream. 35 | 36 | The resulting stream receives messages and sends them on after 37 | sending an INFO message to the logger. In the case of a |JobMessage| 38 | or |ResultMessage| a meaningful message is composed otherwise the 39 | string representation of the object is passed.""" 40 | logger = logging.getLogger('noodles').getChild(name) 41 | # logger.setLevel(logging.DEBUG) 42 | 43 | @stream_type 44 | def log_message(message): 45 | if message is EndOfQueue: 46 | logger.info("-end-of-queue-") 47 | 48 | elif isinstance(message, JobMessage): 49 | logger.info( 50 | "job %10s: %s", message.key, message.node) 51 | 52 | elif isinstance(message, ResultMessage): 53 | job = jobs[message.key] 54 | if is_workflow(message.value): 55 | logger.info( 56 | "result %10s [%s]: %s -> workflow %x", message.key, 57 | job.node, message.status, id(message.value)) 58 | else: 59 | value_string = repr(message.value) 60 | logger.info( 61 | "result %10s [%s]: %s -> %s", message.key, job.node, 62 | message.status, _sugar(value_string)) 63 | 64 | else: 65 | logger.info( 66 | "unknown message: %s", message) 67 | 68 | return message 69 | 70 | return log_message 71 | -------------------------------------------------------------------------------- /notebooks/first_steps-workflow-c.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 139745624703048 14 | 15 | add 16 | (—, —) 17 | 18 | 19 | 20 | 139745624620896 21 | 22 | add 23 | (1, 1) 24 | 25 | 26 | 27 | 139745624620896->139745624703048 28 | 29 | 30 | x 31 | 32 | 33 | 34 | 139745624620896->139745624703048 35 | 36 | 37 | y 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /scripts/tangle: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | script_dir=$(dirname "$(readlink -f "$0")") 4 | format="markdown+fenced_code_attributes+citations+all_symbols_escapable+fenced_divs+multiline_tables" 5 | 6 | rm_if_empty () { 7 | if [ -z "$(ls -A "$1")" ]; then 8 | echo "removing empty dir '${1}'" 9 | rm -r "$1" 10 | rm_if_empty $(dirname "$1") 11 | fi 12 | } 13 | 14 | usage () { 15 | echo "Tangle script" 16 | echo "usage: tangle [options] " 17 | echo 18 | echo "By default, this tangles code files from the list of Markdown" 19 | echo "sources. A file called `tangle.sh` is placed in a build directory." 20 | echo "`tangle.sh` creates the sources files and `rsync`s them with those" 21 | echo "already existing. Default build directory is `./build`." 22 | echo 23 | echo "options:" 24 | echo " --help print this message" 25 | echo " --list list output files" 26 | echo " --script generate tangle script" 27 | echo " --clean delete generated files" 28 | } 29 | 30 | cmd="tangle" 31 | sources="" 32 | 33 | for i in "$@" 34 | do 35 | case $i in 36 | --help) 37 | usage 38 | exit 0 39 | ;; 40 | --list) 41 | cmd="list" 42 | shift 43 | ;; 44 | --clean) 45 | cmd="clean" 46 | shift 47 | ;; 48 | --script) 49 | cmd="script" 50 | shift 51 | ;; 52 | --*) 53 | echo "unknown argument: ${i}" 54 | usage 55 | exit 1 56 | ;; 57 | *) 58 | sources="${sources} ${i}" 59 | shift 60 | ;; 61 | esac 62 | done 63 | 64 | case ${cmd} in 65 | list) 66 | pandoc -f ${format} --lua-filter ${script_dir}/list.lua -t plain ${sources} 67 | ;; 68 | clean) 69 | files=$(pandoc -f ${format} --lua-filter ${script_dir}/list.lua -t plain ${sources}) 70 | for f in ${files}; do 71 | if [ -f "${f}" ]; then 72 | echo "removing '${f}'" 73 | rm "${f}" 74 | rm_if_empty $(dirname "${f}") 75 | fi 76 | done 77 | ;; 78 | tangle) 79 | source <(pandoc -f ${format} --lua-filter ${script_dir}/tangle.lua -t plain ${sources}) 80 | ;; 81 | script) 82 | pandoc -f ${format} --lua-filter ${script_dir}/tangle.lua -t plain ${sources} 83 | ;; 84 | *) 85 | echo "Error: unknown command: ${cmd}" 86 | exit 1 87 | ;; 88 | esac 89 | -------------------------------------------------------------------------------- /noodles/prov/workflow.py: -------------------------------------------------------------------------------- 1 | from ..workflow import (Workflow, is_node_ready, Empty) 2 | from ..workflow.arguments import (serialize_arguments, ref_argument) 3 | from ..serial import (Registry) 4 | from .key import (prov_key) 5 | 6 | 7 | def links(wf, i, deps): 8 | for d in deps: 9 | for l in wf.links[d]: 10 | if l[0] == i: 11 | yield (l[1], wf.nodes[d].prov) 12 | 13 | 14 | def empty_args(n): 15 | for arg in serialize_arguments(n.bound_args): 16 | if ref_argument(n.bound_args, arg) == Empty: 17 | yield arg 18 | 19 | 20 | def set_global_provenance(wf: Workflow, registry: Registry): 21 | """Compute a global provenance key for the entire workflow 22 | before evaluation. This key can be used to store and retrieve 23 | results in a database. The key computed in this stage is different 24 | from the (local) provenance key that can be computed for a node 25 | if all its arguments are known. 26 | 27 | In cases where a result derives from other results that were 28 | computed in child workflows, we can prevent the workflow system 29 | from reevaluating the results at each step to find that we already 30 | had the end-result somewhere. This is where the global prov-key 31 | comes in. 32 | 33 | Each node is assigned a `prov` attribute. If all arguments for this 34 | node are known, this key will be the same as the local prov-key. 35 | If some of the arguments are still empty, we add the global prov-keys 36 | of the dependent nodes to the hash. 37 | 38 | In this algorithm we traverse from the bottom of the DAG to the top 39 | and back using a stack. This allows us to compute the keys for each 40 | node without modifying the node other than setting the `prov` attribute 41 | with the resulting key.""" 42 | stack = [wf.root] 43 | 44 | while stack: 45 | i = stack.pop() 46 | n = wf.nodes[i] 47 | 48 | if n.prov: 49 | continue 50 | 51 | if is_node_ready(n): 52 | job_msg = registry.deep_encode(n) 53 | n.prov = prov_key(job_msg) 54 | continue 55 | 56 | deps = wf.inverse_links[i] 57 | todo = [j for j in deps if not wf.nodes[j].prov] 58 | 59 | if not todo: 60 | link_dict = dict(links(wf, i, deps)) 61 | link_prov = registry.deep_encode( 62 | [link_dict[arg] for arg in empty_args(n)]) 63 | job_msg = registry.deep_encode(n) 64 | n.prov = prov_key(job_msg, link_prov) 65 | continue 66 | 67 | stack.append(i) 68 | stack.extend(deps) 69 | -------------------------------------------------------------------------------- /noodles/run/remote/worker_config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import uuid 3 | import os 4 | import sys 5 | 6 | from noodles import serial 7 | from ...lib import object_name 8 | 9 | 10 | class WorkerConfig(object): 11 | """Configuration for a single remote Job. 12 | 13 | :param name: 14 | A quasi human recognizable name for this job. This will default to 15 | 'remote-xxxxxx' where 'xxxxx' is some UUID. 16 | 17 | :param working_dir: 18 | The work directory where the job runs. Defaults to the current working 19 | directory. 20 | 21 | :param prefix: 22 | The path prefix of the Python system we're running on. This defaults to 23 | `sys.prefix`. If we are in a VirtualEnv, this means that the spawned 24 | job will run in the same VirtualEnv. 25 | 26 | :param exec_command: 27 | The command that is being executed. This defaults to `worker.sh` which 28 | should be located in the `working_dir`. This default script initialises 29 | the VirtualEnv en starts Python with `-m noodles.worker`, acting as a 30 | pilot job. 31 | 32 | :param init: 33 | You may specify a function that needs to be run before any jobs are 34 | being executed. There exist frameworks in the wild, which won't 35 | function otherwise. 36 | 37 | :param finish: 38 | This function may do some clean up, after all jobs have been done. 39 | 40 | :param verbose: 41 | Be verbose about what we're doing. This is for debugging purposes only. 42 | """ 43 | def __init__(self, *, name=None, working_dir=None, prefix=None, 44 | exec_command=None, n_threads=1, registry=serial.base, 45 | init=None, finish=None, verbose=False): 46 | self.name = name or ("remote-" + str(uuid.uuid4())) 47 | self.working_dir = working_dir or os.getcwd() 48 | self.prefix = prefix or Path(sys.prefix) 49 | self.exec_command = exec_command 50 | self.n_threads = n_threads 51 | self.registry = registry 52 | self.init = init 53 | self.finish = finish 54 | self.verbose = verbose 55 | 56 | def command_line(self): 57 | executable = self.prefix / 'bin' / 'python' 58 | 59 | arguments = [ 60 | '-m', 'noodles.pilot_job', 61 | '-name', self.name, 62 | '-registry', object_name(self.registry)] 63 | 64 | if self.init: 65 | arguments.extend(["-init", object_name(self.init)]) 66 | 67 | if self.finish: 68 | arguments.extend(["-finish", object_name(self.finish)]) 69 | 70 | if self.verbose: 71 | arguments.append("-verbose") 72 | 73 | return executable, arguments 74 | -------------------------------------------------------------------------------- /noodles/lib/queue.py: -------------------------------------------------------------------------------- 1 | import queue 2 | from .streams import (push, pull) 3 | from .connection import Connection 4 | 5 | 6 | class EndOfQueue(object): 7 | pass 8 | 9 | 10 | class FlushQueue(object): 11 | pass 12 | 13 | 14 | class Queue(Connection): 15 | """A |Queue| object hides a :py:class:`queue.Queue` object 16 | behind a source and sink interface. 17 | 18 | .. py:attribute:: sink 19 | 20 | Receives items that are put on the queue. Pushing the `end-of-queue` 21 | message through the sink will put it on the queue, and will also result 22 | in a :py:exc:`StopIteration` exception being raised. 23 | 24 | .. py:attribute:: source 25 | 26 | Pull items from the queue. When `end-of-queue` is encountered the 27 | generator returns after re-inserting the `end-of-queue` message on the 28 | queue for other sources to pick up. This way, if many threads are 29 | pulling from this queue, they all get the `end-of-queue` message. 30 | 31 | .. |Queue| replace:: :py:class:`Queue` 32 | """ 33 | def __init__(self, end_of_queue=EndOfQueue): 34 | """ 35 | :param end_of_queue: When this object is encountered in both 36 | the sink and the source, their respective loops are terminated. 37 | Equality is checked using `is`; usualy this is a stub class 38 | designed especially for this purpose. 39 | """ 40 | self._queue = queue.Queue() 41 | self._end_of_queue = end_of_queue 42 | self._flush_queue = FlushQueue 43 | 44 | @push 45 | def sink(): 46 | while True: 47 | r = yield 48 | self._queue.put(r) 49 | 50 | if r is self._flush_queue: 51 | self.flush() 52 | return 53 | 54 | if r is self._end_of_queue: 55 | return 56 | 57 | @pull 58 | def source(): 59 | while True: 60 | v = self._queue.get() 61 | if v is self._end_of_queue: 62 | self._queue.task_done() 63 | self._queue.put(self._end_of_queue) 64 | return 65 | 66 | yield v 67 | self._queue.task_done() 68 | 69 | super(Queue, self).__init__(source, sink) 70 | 71 | def flush(self): 72 | """Erases queue and set `end-of-queue` message.""" 73 | while not self._queue.empty(): 74 | self._queue.get() 75 | self._queue.task_done() 76 | self.close() 77 | 78 | def close(self): 79 | """Sends `end_of_queue` message to the queue. 80 | Doesn't stop running sinks.""" 81 | self._queue.put(self._end_of_queue) 82 | 83 | def empty(self): 84 | return self._queue.empty() 85 | 86 | def wait(self): 87 | self._queue.join() 88 | -------------------------------------------------------------------------------- /test/test_hybrid.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from noodles import schedule, gather 4 | from noodles.workflow import get_workflow 5 | from noodles.run.scheduler import Scheduler 6 | from noodles.run.worker import run_job 7 | from noodles.run.hybrid import hybrid_coroutine_worker 8 | from noodles.run.hybrid import run_hybrid 9 | 10 | from noodles.lib import (Queue, Connection, pull, thread_pool) 11 | from noodles.run.worker import (worker) 12 | from itertools import repeat 13 | 14 | 15 | def single_worker(): 16 | return Queue() >> worker 17 | 18 | 19 | def threaded_worker(n_threads): 20 | return Queue() >> thread_pool(*repeat(worker, n_threads)) 21 | 22 | 23 | @schedule(n=1) 24 | def f(x): 25 | return 2*x 26 | 27 | 28 | @schedule(n=2) 29 | def g(x): 30 | return 3*x 31 | 32 | 33 | @schedule 34 | def h(x, y): 35 | return x + y 36 | 37 | 38 | def selector(job): 39 | if job.hints and 'n' in job.hints: 40 | return job.hints['n'] 41 | else: 42 | return None 43 | 44 | 45 | @schedule(n=1) 46 | def delayed(a, dt): 47 | time.sleep(dt) 48 | return a 49 | 50 | 51 | @schedule 52 | def sum(a, buildin_sum=sum): 53 | return buildin_sum(a) 54 | 55 | 56 | def test_hybrid_threaded_runner_02(): 57 | A = [delayed(1, 0.01) for i in range(8)] 58 | B = sum(gather(*A)) 59 | 60 | start = time.time() 61 | assert run_hybrid(B, selector, {1: threaded_worker(8)}) == 8 62 | end = time.time() 63 | assert (end - start) < 0.05 64 | 65 | 66 | def test_hybrid_threaded_runner_03(): 67 | A = [delayed(1, 0.01) for i in range(8)] 68 | B = sum(gather(*A)) 69 | 70 | start = time.time() 71 | assert run_hybrid(B, selector, {1: single_worker()}) == 8 72 | end = time.time() 73 | assert (end - start) > 0.08 74 | 75 | 76 | def tic_worker(tic): 77 | jobs = Queue() 78 | 79 | @pull 80 | def get_result(): 81 | source = jobs.source() 82 | 83 | for key, job in source: 84 | tic() 85 | yield run_job(key, job) 86 | 87 | return Connection(get_result, jobs.sink) 88 | 89 | 90 | def ticcer(): 91 | a = 0 92 | 93 | def f(): 94 | nonlocal a 95 | a += 1 96 | return a 97 | 98 | def g(): 99 | nonlocal a 100 | return a 101 | 102 | return f, g 103 | 104 | 105 | def test_hybrid_coroutine_runner_03(): 106 | A1 = [f(i) for i in range(11)] 107 | A2 = [g(i) for i in range(7)] 108 | B = sum(gather(*(A1+A2))) 109 | 110 | tic1, c1 = ticcer() 111 | tic2, c2 = ticcer() 112 | 113 | result = Scheduler().run( 114 | hybrid_coroutine_worker(selector, { 115 | 1: tic_worker(tic1), 2: tic_worker(tic2)}), 116 | get_workflow(B)) 117 | 118 | assert c1() == 11 119 | assert c2() == 7 120 | assert result == 173 121 | -------------------------------------------------------------------------------- /notebooks/errors-arithmetic.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 140052213828296 14 | 15 | add 16 | (—, —) 17 | 18 | 19 | 20 | 140052214276840 21 | 22 | reciprocal 23 | (0) 24 | 25 | 26 | 27 | 140052214276840->140052213828296 28 | 29 | 30 | a 31 | 32 | 33 | 34 | 140052214276672 35 | 36 | square_root 37 | (-1) 38 | 39 | 40 | 41 | 140052214276672->140052213828296 42 | 43 | 44 | b 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from test.workflows import workflows 3 | from test.backends import backends 4 | 5 | 6 | def pytest_addoption(parser): 7 | # parser.addoption("--all", action="store_true", 8 | # help="run all combinations") 9 | parser.addoption( 10 | "--workflow", 11 | help="run test only on specified workflow") 12 | parser.addoption( 13 | "--backend", 14 | help="run test only using specified backend") 15 | 16 | 17 | def pytest_generate_tests(metafunc): 18 | if 'workflow' in metafunc.fixturenames: 19 | selection = metafunc.config.getoption('workflow') 20 | if selection is None: 21 | metafunc.parametrize( 22 | "workflow", list(workflows.values()), 23 | ids=list(workflows.keys())) 24 | else: 25 | metafunc.parametrize( 26 | "workflow", [workflows[selection]], 27 | ids=[selection]) 28 | 29 | if 'backend' in metafunc.fixturenames: 30 | selection = metafunc.config.getoption('backend') 31 | if selection is None: 32 | metafunc.parametrize( 33 | "backend", list(backends.values()), 34 | ids=list(backends.keys())) 35 | else: 36 | metafunc.parametrize( 37 | "backend", [backends[selection]], 38 | ids=[selection]) 39 | 40 | 41 | try: 42 | import xenon 43 | except ImportError: 44 | pass 45 | else: 46 | @pytest.fixture(scope="session") 47 | def xenon_server(request): 48 | print("============== Starting Xenon-GRPC server ================") 49 | m = xenon.init(do_not_exit=True, disable_tls=False, log_level='INFO') 50 | yield m 51 | 52 | print("============== Closing Xenon-GRPC server =================") 53 | for scheduler in xenon.Scheduler.list_schedulers(): 54 | jobs = list(scheduler.get_jobs()) 55 | statuses = scheduler.get_job_statuses(jobs) 56 | for status in statuses: 57 | if status.running: 58 | print("xenon job {} still running, cancelling ... " 59 | .format(status.job.id), end='') 60 | try: 61 | status = scheduler.cancel_job(status.job) 62 | if not status.done: 63 | scheduler.wait_until_done(status.job) 64 | except xenon.XenonException: 65 | print("not Ok") 66 | else: 67 | print("Ok") 68 | 69 | m.__exit__(None, None, None) 70 | 71 | @pytest.fixture 72 | def local_filesystem(request, xenon_server): 73 | fs = xenon.FileSystem.create(adaptor='file') 74 | yield fs 75 | fs.close() 76 | 77 | @pytest.fixture 78 | def local_scheduler(request, xenon_server): 79 | scheduler = xenon.Scheduler.create(adaptor='local') 80 | yield scheduler 81 | scheduler.close() 82 | -------------------------------------------------------------------------------- /scripts/weave: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | script_dir=$(dirname "$(readlink -f "$0")") 4 | 5 | format="markdown+fenced_code_attributes+citations+all_symbols_escapable+fenced_divs+multiline_tables+fenced_divs+bracketed_spans" 6 | pandoc_filters="pandoc-eqnos pandoc-fignos pandoc-citeproc" 7 | 8 | filter_args=$(echo ${pandoc_filters} | sed -e 's/\([^ ]*\)/--filter \1/g') 9 | pdf_args="--toc ${filter_args} --lua-filter ${script_dir}/annotate-code-blocks.lua --template ${script_dir}/eisvogel.tex --listings" 10 | html_args="-s --toc --toc-depth=3 ${filter_args} --lua-filter ${script_dir}/annotate-code-blocks.lua --mathjax --css style.css --base-header-level=2" 11 | 12 | target="html" 13 | output="./docs/index.html" 14 | css_file="${script_dir}/style.css" 15 | html_header="${script_dir}/header.html" 16 | sources="" 17 | 18 | usage () { 19 | echo "Weave script" 20 | echo "usage: weave [options] " 21 | echo 22 | echo "By default, this builds HTML output from the list of Markdown" 23 | echo "sources. The output defaults to './public/index.html'." 24 | echo "If `--pdf` is chosen, the default output is set to 'report.pdf'." 25 | echo 26 | echo "options:" 27 | echo " --html generate HTML" 28 | echo " --pdf generate PDF" 29 | echo " --native generate Pandoc native (for debugging)" 30 | echo " --output= name of output file" 31 | echo " --css= name of CSS file" 32 | echo " --header= name of HTML header" 33 | } 34 | 35 | for i in "$@" 36 | do 37 | case $i in 38 | --output=*) 39 | output="${i#*=}" 40 | shift 41 | ;; 42 | --css=*) 43 | css_file="${i#*=}" 44 | shift 45 | ;; 46 | --html) 47 | target="html" 48 | shift 49 | ;; 50 | --header=*) 51 | html_header="${i#*=}" 52 | shift 53 | ;; 54 | --pdf) 55 | target="pdf" 56 | output="report.pdf" 57 | shift 58 | ;; 59 | --native) 60 | target="native" 61 | shift 62 | ;; 63 | --help) 64 | usage 65 | exit 0 66 | ;; 67 | --*) 68 | echo "unknown argument: ${i}" 69 | usage 70 | exit 1 71 | ;; 72 | *) 73 | sources="${sources} ${i}" 74 | ;; 75 | esac 76 | done 77 | 78 | case ${target} in 79 | html) 80 | mkdir -p "$(dirname ${output})" 81 | # cp "${css_file}" "$(dirname ${output})/style.css" 82 | pandoc ${sources} -f ${format} ${html_args} -H ${html_header} -t html5 -o "${output}" 83 | ;; 84 | pdf) 85 | pandoc ${sources} -f ${format} ${pdf_args} -t latex -o "${output}" --pdf-engine=xelatex 86 | ;; 87 | native) 88 | pandoc ${sources} -f ${format} -s -t native 89 | ;; 90 | *) 91 | echo "ERROR: unknown target '${target}'" 92 | exit 1 93 | ;; 94 | esac 95 | -------------------------------------------------------------------------------- /test/lib/test_streams.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | from noodles.lib import ( 3 | pull, push, pull_map, push_map, sink_map, 4 | broadcast, branch, patch, pull_from) 5 | 6 | 7 | def test_pull_chaining(): 8 | @pull 9 | def square(source): 10 | for x in source(): 11 | yield x*x 12 | 13 | squares = pull_from(range(10)) >> square 14 | 15 | assert list(squares) == [i**2 for i in range(10)] 16 | 17 | 18 | def test_pull_mapping(): 19 | @pull_map 20 | def square(x): 21 | return x*x 22 | 23 | squares = pull_from(range(10)) >> square 24 | 25 | assert list(squares) == [i**2 for i in range(10)] 26 | 27 | 28 | def test_function_chaining(): 29 | squares = pull_from(range(10)) >> (lambda x: x*x) 30 | 31 | assert list(squares) == [i**2 for i in range(10)] 32 | 33 | 34 | def test_wrong_chainging_raises_error(): 35 | @push_map 36 | def square(x): 37 | return x*x 38 | 39 | with raises(TypeError): 40 | pull_from(range(10)) >> square 41 | 42 | 43 | def test_push_chaining(): 44 | def square(x): 45 | return x*x 46 | 47 | squares = [] 48 | patch(pull_from(range(10)), push_map(square) >> sink_map(squares.append)) 49 | 50 | assert squares == [i**2 for i in range(10)] 51 | 52 | 53 | def test_branch(): 54 | squares = [] 55 | cubes = [] 56 | 57 | square = push_map(lambda x: x**2) >> sink_map(squares.append) 58 | cube = push_map(lambda x: x**3) >> sink_map(cubes.append) 59 | numbers = list(pull_from(range(10)) >> branch(square, cube)) 60 | assert numbers == list(range(10)) 61 | assert cubes == [i**3 for i in range(10)] 62 | assert squares == [i**2 for i in range(10)] 63 | 64 | 65 | def test_broadcast(): 66 | result1 = [] 67 | result2 = [] 68 | sink = broadcast(sink_map(result1.append), sink_map(result2.append)) 69 | patch(pull_from(range(10)), sink) 70 | 71 | assert result1 == result2 == list(range(10)) 72 | 73 | 74 | def test_pull_00(): 75 | @pull 76 | def f(source): 77 | for i in source(): 78 | yield i**2 79 | 80 | inp = pull(lambda: iter(range(5))) 81 | 82 | def out(lst): 83 | @pull 84 | def g(source): 85 | for i in source(): 86 | lst.append(i) 87 | 88 | return g 89 | 90 | result = [] 91 | pipeline = inp >> f >> out(result) 92 | pipeline() 93 | 94 | assert result == [0, 1, 4, 9, 16] 95 | 96 | 97 | def test_push_00(): 98 | @push 99 | def f(sink): 100 | sink = sink() 101 | while True: 102 | i = yield 103 | sink.send(i**2) 104 | 105 | inp = pull(lambda: iter(range(5))) 106 | 107 | def out(lst): 108 | @push 109 | def g(): 110 | while True: 111 | i = yield 112 | lst.append(i) 113 | 114 | return g 115 | 116 | result = [] 117 | pipeline = f >> out(result) 118 | patch(inp, pipeline) 119 | 120 | assert result == [0, 1, 4, 9, 16] 121 | -------------------------------------------------------------------------------- /scripts/tangle.lua: -------------------------------------------------------------------------------- 1 | -- Copyright 2018 Johan Hidding 2 | 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | -- filename: tangle.lua 16 | -- description: Pandoc filter that generates a Bash script that generates 17 | -- files defined in the code blocks, thus *tangling* the contents. 18 | 19 | local vars = {} 20 | local files = {} 21 | local preamble = [[ 22 | #!/bin/bash 23 | 24 | prepare() { 25 | echo "$1" 26 | mkdir -p $(dirname $1) 27 | } 28 | 29 | echo "Tangling ... " 30 | 31 | tangle_dir=$(mktemp -d /tmp/tangle.XXXXXXXXXX) 32 | target_dir=$(pwd) 33 | 34 | cd "${tangle_dir}" 35 | ]] 36 | 37 | local postamble = [[ 38 | cd "${target_dir}" 39 | 40 | echo -e "\nSyncronising source files ..." 41 | rsync -vrcup ${tangle_dir}/* . 42 | sync 43 | rm -rf ${tangle_dir} 44 | ]] 45 | 46 | function CodeBlock (elem) 47 | if elem.identifier then 48 | t = vars[elem.identifier] or "" 49 | vars[elem.identifier] = t .. "\n" .. elem.text 50 | end 51 | 52 | for k, v in pairs(elem.attr[3]) do 53 | if k == "file" then 54 | files[v] = elem.text 55 | end 56 | end 57 | return nil 58 | end 59 | 60 | function string:split(delimiter) 61 | local result = { } 62 | local from = 1 63 | local delim_from, delim_to = string.find( self, delimiter, from ) 64 | while delim_from do 65 | table.insert( result, string.sub( self, from , delim_from-1 ) ) 66 | from = delim_to + 1 67 | delim_from, delim_to = string.find( self, delimiter, from ) 68 | end 69 | table.insert( result, string.sub( self, from ) ) 70 | return result 71 | end 72 | 73 | function expandCode (pre, key) 74 | local x = "" 75 | for i, line in ipairs(vars[key]:split("\n")) do 76 | x = x .. pre .. line:gsub("(%s*)<<(%g+)>>", expandCode) .. "\n" 77 | end 78 | return x 79 | end 80 | 81 | function expandFile (key) 82 | local x = "" 83 | for i, line in ipairs(files[key]:split("\n")) do 84 | x = x .. line:gsub("(%s*)<<(%g+)>>", expandCode) .. "\n" 85 | end 86 | return x 87 | end 88 | 89 | function Pandoc (elem) 90 | local content = { pandoc.Str(preamble) } 91 | for filename, code in pairs(files) do 92 | code = "prepare " .. filename .. "\n" .. 93 | "cat > " .. filename .. " << EOF\n" .. 94 | expandFile(filename):gsub("\\", "\\\\"):gsub("%$", "\\$"):gsub("`", "\\`") .. 95 | "EOF\n\n" 96 | table.insert(content, pandoc.Str(code)) 97 | end 98 | table.insert(content, pandoc.Str(postamble)) 99 | return pandoc.Pandoc(pandoc.Plain(content)) 100 | end 101 | -------------------------------------------------------------------------------- /test/test_merge_workflow.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | from noodles.workflow import ( 3 | Empty, ArgumentAddress, 4 | ArgumentKind, is_workflow, get_workflow, Workflow) 5 | from noodles import run_single, schedule, gather 6 | 7 | 8 | def dummy(a, b, c, *args, **kwargs): 9 | pass 10 | 11 | 12 | def test_is_workflow(): 13 | assert is_workflow(Workflow(root=None, nodes={}, links={})) 14 | 15 | 16 | def test_get_workflow(): 17 | assert get_workflow(4) is None 18 | 19 | 20 | @schedule 21 | def value(a): 22 | return a 23 | 24 | 25 | @schedule 26 | def add(a, b): 27 | return a+b 28 | 29 | 30 | @schedule 31 | def sub(a, b): 32 | return a - b 33 | 34 | 35 | def test_private(): 36 | a = add(1, 1) 37 | a._private = 3 38 | assert a._private == 3 39 | assert not hasattr(run_single(a), '_private') 40 | 41 | 42 | def test_merge_workflow(): 43 | A = value(1) 44 | B = value(2) 45 | C = add(A, B) 46 | 47 | assert is_workflow(C) 48 | C = get_workflow(C) 49 | A = get_workflow(A) 50 | B = get_workflow(B) 51 | assert C.nodes[C.root].bound_args.args == (Empty, Empty) 52 | assert (C.root, ArgumentAddress(ArgumentKind.regular, 'a', None)) \ 53 | in C.links[A.root] 54 | assert (C.root, ArgumentAddress(ArgumentKind.regular, 'b', None)) \ 55 | in C.links[B.root] 56 | 57 | 58 | def test_binder(): 59 | A = value(1) 60 | B = value(2) 61 | C = gather(A, B) 62 | 63 | C = get_workflow(C) 64 | A = get_workflow(A) 65 | B = get_workflow(B) 66 | 67 | assert is_workflow(C) 68 | assert C.nodes[C.root].bound_args.args == (Empty, Empty) 69 | assert (C.root, ArgumentAddress(ArgumentKind.variadic, 'a', 0)) \ 70 | in C.links[A.root] 71 | assert (C.root, ArgumentAddress(ArgumentKind.variadic, 'a', 1)) \ 72 | in C.links[B.root] 73 | 74 | 75 | @schedule 76 | def takes_keywords(s, **kwargs): 77 | return s 78 | 79 | 80 | def test_with_keywords(): 81 | A = value(1) 82 | B = value(2) 83 | C = takes_keywords(a=A, b=B, s="regular!") 84 | C = get_workflow(C) 85 | A = get_workflow(A) 86 | B = get_workflow(B) 87 | 88 | assert is_workflow(C) 89 | assert C.nodes[C.root].bound_args.args == ("regular!",) 90 | assert C.nodes[C.root].bound_args.kwargs == {'a': Empty, 'b': Empty} 91 | 92 | 93 | class Normal: 94 | pass 95 | 96 | 97 | @schedule 98 | class Scheduled: 99 | pass 100 | 101 | 102 | def test_arg_by_ref(): 103 | n = Normal() 104 | s = Scheduled() 105 | 106 | n.x = 4 107 | s.x = n 108 | n.x = 5 109 | s.y = n 110 | 111 | result = run_single(s) 112 | assert result.x.x == 4 113 | assert result.y.x == 5 114 | 115 | 116 | def test_hidden_promise(): 117 | with raises(TypeError): 118 | a = Normal() 119 | b = Scheduled() 120 | c = Scheduled() 121 | 122 | a.x = b 123 | c.x = a 124 | 125 | 126 | def test_tuple_unpack(): 127 | a = Scheduled() 128 | b = Scheduled() 129 | 130 | a.x, a.y = 2, 3 131 | b.x, b.y = sub(a.x, a.y), sub(a.y, a.x) 132 | 133 | result = run_single(b) 134 | assert result.x == -1 135 | assert result.y == 1 136 | -------------------------------------------------------------------------------- /noodles/patterns/functional_patterns.py: -------------------------------------------------------------------------------- 1 | from .find_first import find_first 2 | from noodles import (gather, schedule, unpack) 3 | from typing import (Any, Callable, Iterable) 4 | 5 | 6 | @schedule 7 | def all(pred: Callable, xs: Iterable): 8 | """ 9 | Check whether all the elements of the iterable `xs` 10 | fullfill predicate `pred`. 11 | 12 | :param pred: 13 | predicate function 14 | :param xs: 15 | iterable object. 16 | :returns: boolean 17 | """ 18 | for x in xs: 19 | if not pred(x): 20 | return False 21 | 22 | return True 23 | 24 | 25 | @schedule 26 | def any(pred: Callable, xs: Iterable): 27 | """ 28 | Check if at least one element of the iterable `xs` 29 | fullfills predicate `pred`. 30 | 31 | :param pred: 32 | predicate function. 33 | :param xs: 34 | iterable object. 35 | :returns: boolean 36 | """ 37 | b = find_first(pred, xs) 38 | 39 | return True if b is not None else False 40 | 41 | 42 | @schedule 43 | def filter(pred: Callable, xs: Iterable): 44 | """ 45 | Applied a predicate to a list returning a :py:class:`PromisedObject` 46 | containing the values satisfying the predicate. 47 | 48 | :param pred: predicate function. 49 | :param xs: iterable object. 50 | :returns: :py:class:`PromisedObject` 51 | """ 52 | generator = (x for x in xs if pred(x)) 53 | 54 | return gather(*generator) 55 | 56 | 57 | @schedule 58 | def fold( 59 | fun: Callable, state: Any, xs: Iterable): 60 | """ 61 | Traverse an iterable object while performing stateful computations 62 | with the elements. It returns a :py:class:`PromisedObject` containing 63 | the result of the stateful computations. 64 | 65 | For a general definition of folding see: 66 | https://en.wikipedia.org/wiki/Fold_(higher-order_function) 67 | 68 | :param fun: stateful function. 69 | :param state: initial state. 70 | :param xs: iterable object. 71 | :returns: :py:class:`PromisedObject` 72 | """ 73 | def generator(state): 74 | for x in xs: 75 | state, r = unpack(fun(state, x), 2) 76 | yield r 77 | 78 | return gather(*generator(state)) 79 | 80 | 81 | @schedule 82 | def map(fun: Callable, xs: Iterable): 83 | """ 84 | Traverse an iterable object applying function `fun` 85 | to each element and finally creats a workflow from it. 86 | 87 | :param fun: 88 | function to call in each element of the iterable 89 | object. 90 | :param xs: 91 | Iterable object. 92 | 93 | returns::py:class:`PromisedObject` 94 | """ 95 | generator = (fun(x) for x in xs) 96 | 97 | return gather(*generator) 98 | 99 | 100 | @schedule 101 | def zip_with(fun: Callable, xs: Iterable, ys: Iterable): 102 | """ 103 | Fuse two Iterable object using the function `fun`. 104 | Notice that if the two objects have different len, 105 | the shortest object gives the result's shape. 106 | 107 | :param fun: 108 | function taking two argument use to process 109 | element x from `xs` and y from `ys`. 110 | 111 | :param xs: 112 | first iterable. 113 | 114 | :param ys: 115 | second iterable. 116 | 117 | returns::py:class:`PromisedObject` 118 | """ 119 | generator = (fun(*rs) for rs in zip(xs, ys)) 120 | 121 | return gather(*generator) 122 | -------------------------------------------------------------------------------- /noodles/display/dumb_term.py: -------------------------------------------------------------------------------- 1 | from .pretty_term import OutStream 2 | from ..workflow import FunctionNode 3 | from inspect import Parameter 4 | import sys 5 | 6 | 7 | def _format_arg_list(a, v): 8 | if len(a) == 0: 9 | if v: 10 | return "(\u2026)" 11 | else: 12 | return "()" 13 | 14 | s = "({0}{1})" 15 | for i in a[:-1]: 16 | s = s.format(str(i) if i != Parameter.empty else "\u2014", ", {0}{1}") 17 | 18 | if v: 19 | return s.format("\u2026", "") 20 | 21 | return s.format(str(a[-1]) if a[-1] != Parameter.empty else "\u2014", "") 22 | 23 | 24 | class DumbDisplay: 25 | """Monochrome, dumb term display""" 26 | def __init__(self, error_filter=None): 27 | self.jobs = {} 28 | self.out = OutStream(sys.stdout) 29 | self.errors = [] 30 | self.error_filter = error_filter 31 | self.messages = [] 32 | 33 | def print_message(self, key, msg): 34 | if key in self.jobs: 35 | print("{1:12} | {2}".format( 36 | key, '['+msg.upper()+']', self.jobs[key]['name']), 37 | file=sys.stderr) 38 | 39 | def add_job(self, key, name): 40 | self.jobs[key] = {'name': name} 41 | 42 | def error_handler(self, job, xcptn): 43 | self.errors.append((job, xcptn)) 44 | 45 | def report(self): 46 | if len(self.errors) == 0: 47 | self.out << "[success]\n" 48 | 49 | else: 50 | self.out << "[ERROR!]\n\n" 51 | 52 | for job, e in self.errors: 53 | msg = 'ERROR ' 54 | if 'display' in job.hints: 55 | msg += job.hints['display'].format( 56 | **job.bound_args.arguments) 57 | else: 58 | msg += 'calling {} with {}'.format( 59 | job.foo.__name__, dict(job.bound_args.arguments) 60 | ) 61 | 62 | print(msg) 63 | err_msg = self.error_filter(e) 64 | if err_msg: 65 | print(err_msg) 66 | else: 67 | print(e) 68 | 69 | def __call__(self, msg): 70 | key, status, data, err = msg 71 | 72 | if isinstance(data, FunctionNode) and hasattr(data, 'hints'): 73 | job = data 74 | if job.hints and 'display' in job.hints: 75 | msg = job.hints['display'].format(**job.bound_args.arguments) 76 | else: 77 | msg = "{0} {1}".format( 78 | job.foo.__name__, 79 | _format_arg_list(job.bound_args.args, None)) 80 | 81 | self.add_job(key, msg) 82 | 83 | if hasattr(self, status): 84 | getattr(self, status)(key, data, err) 85 | else: 86 | self.print_message(key, status) 87 | 88 | def __enter__(self): 89 | return self 90 | 91 | def __exit__(self, exc_type, exc_val, exc_tb): 92 | # self.wait() 93 | 94 | if exc_type: 95 | if exc_type is KeyboardInterrupt: 96 | self.out << "\n" << "User interrupt detected, abnormal exit.\n" 97 | return True 98 | 99 | print("Internal error encountered. Contact the developers.") 100 | return False 101 | 102 | self.report() 103 | 104 | def wait(self): 105 | self.q.wait() 106 | -------------------------------------------------------------------------------- /noodles/interface/maybe.py: -------------------------------------------------------------------------------- 1 | """ 2 | Maybe 3 | ===== 4 | 5 | Facility to handle non-fatal errors in Noodles. 6 | """ 7 | 8 | from functools import (wraps) 9 | from itertools import (chain) 10 | import inspect 11 | from ..lib import (object_name) 12 | 13 | 14 | class Fail: 15 | """Signifies a failure in a computation that was wrapped by a ``@maybe`` 16 | decorator. Because Noodles runs all functions from the same context, it 17 | is not possible to use Python stack traces to find out where an error 18 | happened. In stead we use a ``Fail`` object to store information about 19 | exceptions and the subsequent continuation of the failure.""" 20 | def __init__(self, func, fails=None, exception=None): 21 | try: 22 | self.name = "{} ({}:{})".format( 23 | object_name(func), 24 | inspect.getsourcefile(func), 25 | inspect.getsourcelines(func)[1]) 26 | except AttributeError: 27 | self.name = "<{} instance>".format(func.__class__.__name__) 28 | 29 | self.fails = fails or [] 30 | self.trace = [] 31 | self.exception = exception 32 | 33 | def add_call(self, func): 34 | """Add a call to the trace.""" 35 | self.trace.append("{} ({}:{})".format( 36 | object_name(func), 37 | inspect.getsourcefile(func), 38 | inspect.getsourcelines(func)[1])) 39 | 40 | return self 41 | 42 | @property 43 | def is_root_cause(self): 44 | """If the field ``exception`` is set in this object, it means 45 | that we are looking at the root cause of the failure.""" 46 | return self.exception is not None 47 | 48 | def __bool__(self): 49 | return False 50 | 51 | def __str__(self): 52 | msg = "Fail: " + " -> ".join(self.trace + [self.name]) 53 | if self.exception is not None: 54 | msg += "\n* {}: ".format(type(self.exception).__name__) 55 | msg += "\n ".join(l for l in str(self.exception).split('\n')) 56 | elif self.fails: 57 | msg += "\n* failed arguments:\n " 58 | msg += "\n ".join( 59 | "{} `{}` ".format(func, source) + "\n ".join( 60 | l for l in str(fail).split('\n')) 61 | for func, source, fail in self.fails) 62 | return msg 63 | 64 | 65 | def failed(obj): 66 | """Returns True if ``obj`` is an instance of ``Fail``.""" 67 | return isinstance(obj, Fail) 68 | 69 | 70 | def maybe(func): 71 | """Calls `f` in a try/except block, returning a `Fail` object if 72 | the call fails in any way. If any of the arguments to the call are Fail 73 | objects, the call is not attempted.""" 74 | 75 | name = object_name(func) 76 | 77 | @wraps(func) 78 | def maybe_wrapped(*args, **kwargs): 79 | """@maybe wrapped version of ``func``.""" 80 | fails = [ 81 | (name, k, v) 82 | for k, v in chain(enumerate(args), kwargs.items()) 83 | if isinstance(v, Fail)] 84 | 85 | if fails: 86 | return Fail(func, fails=fails) 87 | 88 | try: 89 | result = func(*args, **kwargs) 90 | 91 | except Exception as exc: 92 | return Fail(func, exception=exc) 93 | 94 | else: 95 | if isinstance(result, Fail): 96 | result.add_call(func) 97 | 98 | return result 99 | 100 | return maybe_wrapped 101 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at j.hidding@esciencecenter.nl. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Noodles - parallel programming in Python 8 | 14 | 15 | 18 | 19 | 20 | 21 |
22 |

Noodles - parallel programming in Python

23 |
24 | 31 |

Travis Zenodo DOI Code coverage Documentation

32 |
33 | 49 |
50 |

What is Noodles?

51 |

Noodles is a task-based parallel programming model in Python that offers the same intuitive interface when running complex workflows on your laptop or on large computer clusters.

52 |

Installation

53 |

To install the latest version from PyPI:

54 |
pip install noodles
55 |

To enable the Xenon backend for remote job execution,

56 |
pip install noodles[xenon]
57 |

This requires a Java Runtime to be installed, you may check this by running

58 |
java --version
59 |

which should print the version of the currently installed JRE.

60 |

Documentation

61 |

All the latest documentation is available on Read the Docs.

62 | 63 | 64 | -------------------------------------------------------------------------------- /notebooks/inspecting_db.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "!rm -f tutorial.db" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": 2, 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import noodles\n", 19 | "from noodles.run.single.sqlite3 import run_single" 20 | ] 21 | }, 22 | { 23 | "cell_type": "code", 24 | "execution_count": 12, 25 | "metadata": {}, 26 | "outputs": [ 27 | { 28 | "data": { 29 | "text/plain": [ 30 | "[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]" 31 | ] 32 | }, 33 | "execution_count": 12, 34 | "metadata": {}, 35 | "output_type": "execute_result" 36 | } 37 | ], 38 | "source": [ 39 | "@noodles.schedule\n", 40 | "def double(x):\n", 41 | " return x*2\n", 42 | "\n", 43 | "workflow = noodles.gather_all(double(i) for i in range(10))\n", 44 | "run_single(workflow, registry=noodles.serial.base,\n", 45 | " db_file='tutorial.db')" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": 4, 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "from noodles.prov.sqlite import JobDB" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": 5, 60 | "metadata": {}, 61 | "outputs": [], 62 | "source": [ 63 | "db = JobDB('tutorial.db', registry=noodles.serial.base)" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": 6, 69 | "metadata": {}, 70 | "outputs": [ 71 | { 72 | "name": "stdout", 73 | "output_type": "stream", 74 | "text": [ 75 | " 1: double(0)\n", 76 | " 2: double(1)\n", 77 | " 3: double(2)\n", 78 | " 4: double(3)\n", 79 | " 5: double(4)\n", 80 | " 6: double(5)\n", 81 | " 7: double(6)\n", 82 | " 8: double(7)\n", 83 | " 9: double(8)\n", 84 | " 10: double(9)\n", 85 | " 11: gather(0, 2, 4, 6, 8, 10, 12, 14, 16, 18)\n" 86 | ] 87 | } 88 | ], 89 | "source": [ 90 | "for k, v in db.list_jobs().items():\n", 91 | " print('{:8}: {}'.format(k, v))" 92 | ] 93 | }, 94 | { 95 | "cell_type": "code", 96 | "execution_count": 9, 97 | "metadata": {}, 98 | "outputs": [ 99 | { 100 | "data": { 101 | "text/plain": [ 102 | "[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]" 103 | ] 104 | }, 105 | "execution_count": 9, 106 | "metadata": {}, 107 | "output_type": "execute_result" 108 | } 109 | ], 110 | "source": [ 111 | "db.get_result(11)" 112 | ] 113 | }, 114 | { 115 | "cell_type": "code", 116 | "execution_count": 11, 117 | "metadata": {}, 118 | "outputs": [], 119 | "source": [ 120 | "db.connection.close()" 121 | ] 122 | }, 123 | { 124 | "cell_type": "code", 125 | "execution_count": null, 126 | "metadata": {}, 127 | "outputs": [], 128 | "source": [] 129 | } 130 | ], 131 | "metadata": { 132 | "kernelspec": { 133 | "display_name": "Python 3", 134 | "language": "python", 135 | "name": "python3" 136 | }, 137 | "language_info": { 138 | "codemirror_mode": { 139 | "name": "ipython", 140 | "version": 3 141 | }, 142 | "file_extension": ".py", 143 | "mimetype": "text/x-python", 144 | "name": "python", 145 | "nbconvert_exporter": "python", 146 | "pygments_lexer": "ipython3", 147 | "version": "3.7.3" 148 | } 149 | }, 150 | "nbformat": 4, 151 | "nbformat_minor": 2 152 | } 153 | -------------------------------------------------------------------------------- /doc/source/cooking.rst: -------------------------------------------------------------------------------- 1 | Cooking of Noodles (library docs) 2 | ================================= 3 | 4 | The cooking of good Noodles can be tricky. We try to make it as easy as possible, but to write good Noodles you need to settle in a *functional style* of programming. The functions you design cannot write to some global state, or modify its arguments and expect these modifications to persist throughout the program. This is not a restriction of Noodles itself, this is a fundamental principle that applies to all possible frameworks for parallel and distributed programming. So get used to it! 5 | 6 | Every function call in Noodles (that is, calls to scheduled function) can be visualised as a node in a call graph. You should be able to draw this graph conceptually when designing the program. Luckily there is (almost) always a way to write down non-functional code in a functional way. 7 | 8 | .. NOTE:: Golden Rule: if you modify something, return it. 9 | 10 | 11 | Call by value 12 | ------------- 13 | 14 | Suppose we have the following program 15 | 16 | :: 17 | 18 | from noodles import (schedule, run_single) 19 | 20 | @schedule 21 | def double(x): 22 | return x['value'] * 2 23 | 24 | @schedule 25 | def add(x, y): 26 | return x + y 27 | 28 | a = {'value': 4} 29 | b = double(a) 30 | a['value'] = 5 31 | c = double(a) 32 | d = add(b, c) 33 | 34 | print(run_single(d)) 35 | 36 | If this were undecorated Python, the answer would be 18. However, the computation of this answer depends on the time-dependency of the Python interpreter. In Python, dictionaries are passed by reference. The promised object `b` then contains a reference to the dictionary in `a`. If we then change the value in this dictionary, the call producing the value of `b` is retroactively changed to double the value 5 instead of 4. 37 | 38 | If Noodles is to evaluate this program correctly it needs to :py:func:`deepcopy` every argument to a scheduled function. There is another way to have the same semantics produce a correct result. This is by making `a` a promised object in the first place. The third solution is to teach your user *functional programming*. 39 | Deep copying function arguments can result in a significant performance penalty on the side of the job scheduler. In most applications that we target this is not the bottle neck. 40 | 41 | Since we aim for the maximum ease of use for the end-user, we chose to enable call-by-value by default. 42 | 43 | 44 | Monads (sort of) 45 | ---------------- 46 | 47 | We still have ways to do object oriented programming and assignments. The :py:class:`PromisedObject` class has several magic methods overloaded to translate to functional equivalents. 48 | 49 | Member assignment 50 | ~~~~~~~~~~~~~~~~~ 51 | 52 | Especially member assignment is treated in a particular way. Suppose ``a`` is a :py:class:`PromisedObject`, then the statement 53 | 54 | :: 55 | 56 | a.b = 3 57 | 58 | is (conceptually) transformed into 59 | 60 | :: 61 | 62 | a = _setattr(a, 'b', 3) 63 | 64 | where :py:func:`_setattr` is a scheduled function. The :py:class:`PromisedObject` contains a representation of the complete workflow representing the computation to get to the value of `a`. In member assignment, this workflow is replaced with the new workflow containing this last instruction. 65 | 66 | This is not a recommended way of programming. Every assignment results in a nested function call. The `statefulness` of the program is then implemented in the composition of functions, similar to how other functional languages do it using `monads`. It results in sequential code that will not parallelise so well. 67 | 68 | Other magic methods 69 | ~~~~~~~~~~~~~~~~~~~ 70 | 71 | Next to member assignment, we also (obviously) support member reference, method function call and object function call (with `__call__`). 72 | 73 | 74 | Storable 75 | -------- 76 | 77 | 78 | 79 | Serialisation 80 | ------------- 81 | -------------------------------------------------------------------------------- /noodles/run/xenon/xenon.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import xenon 3 | 4 | from ..remote.worker_config import WorkerConfig 5 | 6 | 7 | class XenonJobConfig(WorkerConfig): 8 | def __init__( 9 | self, *, queue_name=None, environment=None, 10 | time_out=1000, scheduler_arguments=None, **kwargs): 11 | super(XenonJobConfig, self).__init__(**kwargs) 12 | self.time_out = time_out 13 | 14 | executable, arguments = self.command_line() 15 | self.xenon_job_description = xenon.JobDescription( 16 | executable=str(executable), 17 | arguments=arguments, 18 | working_directory=str(self.working_dir), 19 | queue_name=queue_name, 20 | environment=environment, 21 | scheduler_arguments=scheduler_arguments) 22 | 23 | 24 | class Machine(object): 25 | """Configuration to the Xenon library. 26 | 27 | Xenon is a Java library that offers a uniform interface to execute jobs. 28 | These jobs may be run locally, over ssh ar against a queue manager like 29 | SLURM. 30 | 31 | [Documentation to PyXenon can be found online](http://pyxenon.rtfd.io/) 32 | 33 | :param name: 34 | The quasi human readable name to give to this Xenon instance. 35 | This defaults to a generated UUID. 36 | 37 | :param jobs_scheme: 38 | The scheme by which to schedule jobs. Should be one of 'local', 'ssh', 39 | 'slurm' etc. See the Xenon documentation. 40 | 41 | :param files_scheme: 42 | The scheme by which to transfer files. Should be 'local' or 'ssh'. 43 | See the Xenon documentation. 44 | 45 | :param location: 46 | A location. This can be the host of the 'ssh' or 'slurm' server. 47 | 48 | :param credential: 49 | To enter a server through ssh, we need to have some credentials. 50 | Preferably, you have a private/public key pair by which you can 51 | identify yourself. Otherwise, this would be a combination of 52 | username/password. This functions that can create a credential 53 | object can be found in Xenon.credentials in the Xenon documentation. 54 | 55 | :param jobs_properties: 56 | Configuration to the Xenon.jobs module. 57 | 58 | :param files_properties: 59 | Configuration to the Xenon.files module. 60 | """ 61 | def __init__(self, *, name=None, scheduler_adaptor='local', 62 | location=None, credential=None, jobs_properties=None, 63 | files_properties=None): 64 | self.name = name or ("xenon-" + str(uuid.uuid4())) 65 | self.scheduler_adaptor = scheduler_adaptor 66 | self.location = location 67 | self.credential = credential 68 | self.jobs_properties = jobs_properties 69 | self.files_properties = files_properties 70 | self._scheduler = None 71 | self._file_system = None 72 | 73 | @property 74 | def scheduler_args(self): 75 | args = {'adaptor': self.scheduler_adaptor, 76 | 'location': self.location, 77 | 'properties': self.jobs_properties} 78 | 79 | if isinstance(self.credential, xenon.PasswordCredential): 80 | args['password_credential'] = self.credential 81 | if isinstance(self.credential, xenon.CertificateCredential): 82 | args['certificate_credential'] = self.credential 83 | 84 | return args 85 | 86 | @property 87 | def scheduler(self): 88 | """Returns the scheduler object.""" 89 | if self._scheduler is None: 90 | self._scheduler = xenon.Scheduler.create(**self.scheduler_args) 91 | 92 | return self._scheduler 93 | 94 | @property 95 | def file_system(self): 96 | """Gets the filesystem corresponding to the open scheduler.""" 97 | if self._file_system is None: 98 | self._file_system = self.scheduler.get_file_system() 99 | 100 | return self._file_system 101 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | body 2 | { 3 | margin: 0 auto 0 250pt; 4 | max-width: 600pt; 5 | background: white; 6 | padding: 0 10pt 0 0; 7 | /* font-family: "Cantarell", "Verdana", sans-serif; */ 8 | font-family: "Akkurat Light", arial; 9 | font-size: 14pt; 10 | counter-reset: h1; 11 | } 12 | 13 | .splash { 14 | background: #00aeef; 15 | color: white; 16 | font-family: "Akkurat", arial; 17 | text-shadow: 1px 1px 2px rgba(0,0,0,0.3); 18 | padding: 10pt 10pt 15pt 20pt; 19 | margin-bottom: 50pt; 20 | margin-top: 50pt; 21 | font-size: 18pt; 22 | line-height: 30pt; 23 | border: thin solid black; 24 | /*border-radius: 10pt;*/ 25 | box-shadow: 10px 10px 10px 0px rgba(0,0,0,0.3); 26 | } 27 | 28 | .splash ul { 29 | margin-bottom: 10pt; 30 | } 31 | 32 | .splash a { 33 | font-family: "Akkurat Light", arial; 34 | /* color: #cceeff; */ 35 | color: white; 36 | text-decoration: underline; 37 | } 38 | 39 | .splash a:hover { 40 | color: white; 41 | } 42 | 43 | p { 44 | text-align: justify; 45 | } 46 | 47 | a { 48 | color: rgb(102, 41, 41); 49 | text-decoration: none; 50 | } 51 | 52 | .TODO { 53 | background: pink; 54 | border: solid thin black; 55 | margin: 5pt 30pt 5pt 30pt; 56 | padding: 3pt; 57 | box-shadow: 8pt 8pt 10pt 0pt #aaa; 58 | } 59 | 60 | .TODO :before { 61 | content: "👷 TODO: "; 62 | font-weight: bold; 63 | } 64 | 65 | h2 { 66 | color: #00aeef; 67 | counter-reset: h2; 68 | } 69 | 70 | h3 { 71 | counter-reset: h3; 72 | } 73 | 74 | h2:before { 75 | content: counter(h1) ".\0000a0\0000a0"; 76 | counter-increment: h1; 77 | } 78 | 79 | h3:before { 80 | content: counter(h1) "." counter(h2) ".\0000a0\0000a0"; 81 | counter-increment: h2; 82 | } 83 | 84 | h4:before { 85 | content: counter(h1) "." counter(h2) "." counter(h3) ".\0000a0\0000a0"; 86 | counter-increment: h3; 87 | } 88 | 89 | #TOC 90 | { 91 | background-color: #00aeef; 92 | background-image:url('nlesc-logo.svg'); 93 | background-repeat: no-repeat; 94 | background-position: center 20pt; 95 | font-family: "Akkurat", arial; 96 | font-size: smaller; 97 | position: fixed; 98 | top: 0pt; 99 | left: 0pt; 100 | bottom: 0pt; 101 | border-right: solid thin rgb(255, 255, 255); 102 | padding: 20pt; 103 | padding-right: 25pt; 104 | padding-top: 50pt; 105 | width: 150pt; 106 | color: #FFF; 107 | border-right: thin solid black; 108 | } 109 | 110 | #TOC a { 111 | color: #eee; 112 | text-decoration: none; 113 | } 114 | 115 | #TOC ul { 116 | padding: 3pt 0 3pt 10pt; 117 | } 118 | 119 | .noweb { 120 | font-style: italic; 121 | font-size: smaller; 122 | } 123 | 124 | code 125 | { 126 | font-family: "Inconsolata", monospace; 127 | } 128 | 129 | .sourceCode, .elm 130 | { 131 | font-size: 10pt; 132 | border: solid thin #aaa; 133 | border-radius: 5pt; 134 | background: #eff2f2; 135 | padding: 10pt; 136 | 137 | width: 40em; 138 | position: relative; 139 | left: 25pt; 140 | } 141 | 142 | .elm pre, .sourceCode pre, .sourceCode code 143 | { 144 | padding: unset; 145 | background: none; 146 | border: none; 147 | width: unset; 148 | left: unset; 149 | } 150 | 151 | figure 152 | { 153 | border: solid 1px #aaa; 154 | border-radius: 5pt; 155 | padding: 10pt; 156 | background: white; 157 | box-shadow: 8pt 8pt 10pt 0pt #aaa; 158 | text-align: center; 159 | } 160 | 161 | figcaption 162 | { 163 | text-align: justify; 164 | font-style: italic; 165 | } 166 | 167 | figure img 168 | { 169 | width: 100%; 170 | margin: 0 auto 0 auto; 171 | } 172 | 173 | blockquote { 174 | background: #eff2f2; 175 | color: black; 176 | padding: 10pt 30pt; 177 | font-family: Akkurat; 178 | border: thin solid black; 179 | border-radius: 10pt; 180 | /* box-shadow: 10pt 10pt #00aeef;*/ 181 | } 182 | 183 | -------------------------------------------------------------------------------- /noodles/run/job_keeper.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import time 3 | import json 4 | import sys 5 | 6 | from threading import Lock 7 | from ..lib import (coroutine, EndOfQueue) 8 | from .messages import (JobMessage, EndOfWork) 9 | 10 | 11 | class JobKeeper(dict): 12 | def __init__(self, keep=False): 13 | super(JobKeeper, self).__init__() 14 | self.keep = keep 15 | self.lock = Lock() 16 | self.workflows = {} 17 | 18 | def register(self, job): 19 | with self.lock: 20 | key = str(uuid.uuid4()) 21 | job.db_id = None 22 | job.log = [] 23 | job.log.append((time.time(), 'register', None, None)) 24 | self[key] = job 25 | 26 | return JobMessage(key, job.node) 27 | 28 | def __delitem__(self, key): 29 | if not self.keep: 30 | super(JobKeeper, self).__delitem__(key) 31 | 32 | def store_result(self, key, status, value, err): 33 | if status != 'done': 34 | return 35 | 36 | if key not in self: 37 | print("WARNING: store_result called but job not in registry:\n" 38 | " race condition? Not doing anything.\n", file=sys.stderr) 39 | return 40 | 41 | with self.lock: 42 | job = self[key] 43 | job.node.result = value 44 | 45 | @coroutine 46 | def message(self): 47 | while True: 48 | msg = yield 49 | 50 | if msg is EndOfQueue: 51 | return 52 | if msg is None: 53 | print("Warning: `None` received where not expected.", 54 | file=sys.stderr) 55 | return 56 | 57 | key, status, value, err = msg 58 | 59 | with self.lock: 60 | if key not in self: 61 | continue 62 | 63 | job = self[key] 64 | job.log.append((time.time(), status, value, err)) 65 | 66 | 67 | class JobTimer(dict): 68 | def __init__(self, timing_file, registry=None): 69 | super(JobTimer, self).__init__() 70 | self.workflows = {} 71 | 72 | if isinstance(timing_file, str): 73 | self.fo = open(timing_file, 'w') 74 | self.owner = True 75 | else: 76 | self.fo = timing_file 77 | self.owner = False 78 | 79 | def register(self, job): 80 | key = str(uuid.uuid4()) 81 | job.sched_time = time.time() 82 | self[key] = job 83 | return JobMessage(key, job.node) 84 | 85 | def __delitem__(self, key): 86 | pass 87 | 88 | # def message(self, key, status, value, err): 89 | @coroutine 90 | def message(self): 91 | while True: 92 | msg = yield 93 | if msg is EndOfWork: 94 | return 95 | key, status, value, err = msg 96 | if hasattr(self, status): 97 | getattr(self, status)(key, value, err) 98 | 99 | def start(self, key, value, err): 100 | self[key].start_time = time.time() 101 | 102 | def done(self, key, value, err): 103 | job = self[key] 104 | now = time.time() 105 | if job.node.hints and 'display' in job.node.hints: 106 | msg_obj = { 107 | 'description': job.node.hints['display'].format( 108 | **job.node.bound_args.arguments), 109 | 'schedule_time': time.strftime( 110 | '%Y-%m-%dT%H:%M:%SZ', time.gmtime(job.sched_time)), 111 | 'start_time': time.strftime( 112 | '%Y-%m-%dT%H:%M:%SZ', time.gmtime(job.start_time)), 113 | 'done_time': time.strftime( 114 | '%Y-%m-%dT%H:%M:%SZ', time.gmtime(now)), 115 | 'run_duration': now - job.start_time} 116 | self.fo.write('{record},\n'.format(record=json.dumps( 117 | msg_obj, indent=2))) 118 | 119 | def __enter__(self): 120 | return self 121 | 122 | def __exit__(self, e_type, e_value, e_tb): 123 | if self.owner: 124 | self.fo.close() 125 | -------------------------------------------------------------------------------- /noodles/run/threading/sqlite3.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implements parallel worker with Sqlite database. 3 | """ 4 | 5 | from itertools import repeat 6 | import logging 7 | 8 | from ..scheduler import (Scheduler) 9 | from ..messages import (ResultMessage) 10 | from ..worker import (worker) 11 | from ..logging import make_logger 12 | 13 | from ...workflow import (get_workflow) 14 | from ...prov.sqlite import (JobDB) 15 | from ...lib import ( 16 | Queue, pull, thread_pool, Connection, EndOfQueue, 17 | pull_map, push_map) 18 | 19 | 20 | def pass_job(db: JobDB, result_queue: Queue, always_cache=False): 21 | """Create a pull stream that receives jobs and passes them on to the 22 | database. If the job already has a result, that result is pushed onto 23 | the `result_queue`. 24 | """ 25 | @pull 26 | def pass_job_stream(job_source): 27 | """Pull stream instance created by `pass_job`.""" 28 | result_sink = result_queue.sink() 29 | 30 | for message in job_source(): 31 | if message is EndOfQueue: 32 | return 33 | 34 | key, job = message 35 | if always_cache or ('store' in job.hints): 36 | status, retrieved_result = db.add_job_to_db(key, job) 37 | 38 | if status == 'retrieved': 39 | result_sink.send(retrieved_result) 40 | continue 41 | 42 | elif status == 'attached': 43 | continue 44 | 45 | yield message 46 | 47 | return pass_job_stream 48 | 49 | 50 | def pass_result(db: JobDB, always_cache=False): 51 | """Creates a pull stream receiving results, storing them in the database, 52 | then sending them on. At this stage, the database may return a list of 53 | attached jobs which also need to be sent on to the scheduler.""" 54 | @pull 55 | def pass_result_stream(worker_source): 56 | """Pull stream instance created by `pass_result`.""" 57 | for result in worker_source(): 58 | if result is EndOfQueue: 59 | return 60 | 61 | attached = db.store_result_in_db( 62 | result, always_cache=always_cache) 63 | 64 | yield result 65 | yield from (ResultMessage(key, 'attached', result.value, None) 66 | for key in attached) 67 | 68 | return pass_result_stream 69 | 70 | 71 | def run_parallel( 72 | workflow, *, n_threads, registry, db_file, echo_log=True, 73 | always_cache=False): 74 | """Run a workflow in parallel threads, storing results in a Sqlite3 75 | database. 76 | 77 | :param workflow: Workflow or PromisedObject to evaluate. 78 | :param n_threads: number of threads to use (in addition to the scheduler). 79 | :param registry: serialization Registry function. 80 | :param db_file: filename of Sqlite3 database, give `':memory:'` to 81 | keep the database in memory only. 82 | :param echo_log: set log-level high enough 83 | :param always_cache: enable caching by schedule hint. 84 | :return: Evaluated result. 85 | """ 86 | if echo_log: 87 | logging.getLogger('noodles').setLevel(logging.DEBUG) 88 | logging.debug("--- start log ---") 89 | 90 | with JobDB(db_file, registry) as db: 91 | job_queue = Queue() 92 | result_queue = Queue() 93 | 94 | job_logger = make_logger("worker", push_map, db) 95 | result_logger = make_logger("worker", pull_map, db) 96 | 97 | worker_pool = job_queue.source \ 98 | >> pass_job(db, result_queue, always_cache) \ 99 | >> thread_pool(*repeat(worker, n_threads), results=result_queue) 100 | job_front_end = job_logger >> job_queue.sink 101 | result_front_end = worker_pool \ 102 | >> pass_result(db, always_cache) \ 103 | >> result_logger 104 | 105 | scheduler = Scheduler(job_keeper=db) 106 | parallel_sqlite_worker = Connection(result_front_end, job_front_end) 107 | 108 | result = scheduler.run(parallel_sqlite_worker, get_workflow(workflow)) 109 | 110 | return registry().dereference(result) 111 | -------------------------------------------------------------------------------- /notebooks/first_steps.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# First Steps\n", 8 | "\n", 9 | "**This tutorial is also available in the form of a Jupyter Notebook. Try it out, and play!**\n", 10 | "\n", 11 | "Noodles is there to make your life easier, *in parallel*! The reason why Noodles can be easy and do parallel Python at the same time is its *functional* approach. In one part you'll define a set of functions that you'd like to run with Noodles, in an other part you'll compose these functions into a *workflow graph*. To make this approach work a function should not have any *side effects*. Let's not linger and just start noodling! First we define some functions to use." 12 | ] 13 | }, 14 | { 15 | "cell_type": "code", 16 | "execution_count": 1, 17 | "metadata": { 18 | "collapsed": true 19 | }, 20 | "outputs": [], 21 | "source": [ 22 | "from noodles import schedule\n", 23 | "\n", 24 | "@schedule\n", 25 | "def add(x, y):\n", 26 | " return x + y\n", 27 | "\n", 28 | "@schedule\n", 29 | "def mul(x,y):\n", 30 | " return x * y" 31 | ] 32 | }, 33 | { 34 | "cell_type": "markdown", 35 | "metadata": {}, 36 | "source": [ 37 | "Now we can create a workflow composing several calls to this function." 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": 2, 43 | "metadata": { 44 | "collapsed": true 45 | }, 46 | "outputs": [], 47 | "source": [ 48 | "a = add(1, 1)\n", 49 | "b = mul(a, 2)\n", 50 | "c = add(a, a)\n", 51 | "d = mul(b, c)" 52 | ] 53 | }, 54 | { 55 | "cell_type": "markdown", 56 | "metadata": {}, 57 | "source": [ 58 | "That looks easy enough; the funny thing is though, that nothing has been computed yet! Noodles just created the workflow graphs corresponding to the values that still need to be computed. Until such time, we work with the *promise* of a future value. Using some function in `pygraphviz` we can look at the call graphs." 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": 3, 64 | "metadata": {}, 65 | "outputs": [ 66 | { 67 | "data": { 68 | "text/markdown": [ 69 | "| a | b | c | d |\n", 70 | "| --- | --- | --- | --- |\n", 71 | "| ![workflow a](first_steps-workflow-a.svg) | ![workflow b](first_steps-workflow-b.svg) | ![workflow c](first_steps-workflow-c.svg) | ![workflow d](first_steps-workflow-d.svg) |" 72 | ], 73 | "text/plain": [ 74 | "" 75 | ] 76 | }, 77 | "metadata": {}, 78 | "output_type": "display_data" 79 | } 80 | ], 81 | "source": [ 82 | "from noodles.tutorial import display_workflows\n", 83 | "\n", 84 | "display_workflows(prefix='first_steps-workflow',\n", 85 | " a=a, b=b, c=c, d=d)" 86 | ] 87 | }, 88 | { 89 | "cell_type": "markdown", 90 | "metadata": {}, 91 | "source": [ 92 | "Now, to compute the result we have to tell Noodles to evaluate the program." 93 | ] 94 | }, 95 | { 96 | "cell_type": "code", 97 | "execution_count": 4, 98 | "metadata": {}, 99 | "outputs": [ 100 | { 101 | "data": { 102 | "text/plain": [ 103 | "16" 104 | ] 105 | }, 106 | "execution_count": 4, 107 | "metadata": {}, 108 | "output_type": "execute_result" 109 | } 110 | ], 111 | "source": [ 112 | "from noodles import run_parallel\n", 113 | "\n", 114 | "run_parallel(d, n_threads=2)" 115 | ] 116 | } 117 | ], 118 | "metadata": { 119 | "kernelspec": { 120 | "display_name": "Python 3", 121 | "language": "python", 122 | "name": "python3" 123 | }, 124 | "language_info": { 125 | "codemirror_mode": { 126 | "name": "ipython", 127 | "version": 3 128 | }, 129 | "file_extension": ".py", 130 | "mimetype": "text/x-python", 131 | "name": "python", 132 | "nbconvert_exporter": "python", 133 | "pygments_lexer": "ipython3", 134 | "version": "3.7.3" 135 | } 136 | }, 137 | "nbformat": 4, 138 | "nbformat_minor": 2 139 | } 140 | -------------------------------------------------------------------------------- /noodles/run/hybrid.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | from ..workflow import get_workflow 4 | from ..lib import Queue, Connection, push, patch, EndOfQueue, FlushQueue 5 | from .scheduler import Scheduler 6 | from .worker import run_job 7 | 8 | 9 | def hybrid_coroutine_worker(selector, workers): 10 | """Runs a set of workers, all of them in the main thread. 11 | This runner is here for testing purposes. 12 | 13 | :param selector: 14 | A function returning a worker key, given a job. 15 | :type selector: function 16 | 17 | :param workers: 18 | A dict of workers. 19 | :type workers: dict 20 | """ 21 | jobs = Queue() 22 | 23 | worker_source = {} 24 | worker_sink = {} 25 | 26 | for k, w in workers.items(): 27 | worker_source[k], worker_sink[k] = w.setup() 28 | 29 | def get_result(): 30 | source = jobs.source() 31 | 32 | for msg in source: 33 | key, job = msg 34 | worker = selector(job) 35 | if worker is None: 36 | yield run_job(key, job) 37 | else: 38 | # send the worker a job and wait for it to return 39 | worker_sink[worker].send(msg) 40 | result = next(worker_source[worker]) 41 | yield result 42 | 43 | return Connection(get_result, jobs.sink) 44 | 45 | 46 | def hybrid_threaded_worker(selector, workers): 47 | """Runs a set of workers, each in a separate thread. 48 | 49 | :param selector: 50 | A function that takes a hints-tuple and returns a key 51 | indexing a worker in the `workers` dictionary. 52 | :param workers: 53 | A dictionary of workers. 54 | 55 | :returns: 56 | A connection for the scheduler. 57 | :rtype: Connection 58 | 59 | The hybrid worker dispatches jobs to the different workers 60 | based on the information contained in the hints. If no hints 61 | were given, the job is run in the main thread. 62 | 63 | Dispatching is done in the main thread. Retrieving results is 64 | done in a separate thread for each worker. In this design it is 65 | assumed that dispatching a job takes little time, while waiting for 66 | one to return a result may take a long time. 67 | """ 68 | result_queue = Queue() 69 | 70 | job_sink = {k: w.sink() for k, w in workers.items()} 71 | 72 | @push 73 | def dispatch_job(): 74 | default_sink = result_queue.sink() 75 | 76 | while True: 77 | msg = yield 78 | 79 | if msg is EndOfQueue: 80 | for k in workers.keys(): 81 | try: 82 | job_sink[k].send(EndOfQueue) 83 | except StopIteration: 84 | pass 85 | return 86 | 87 | if msg is FlushQueue: 88 | for k in workers.keys(): 89 | try: 90 | job_sink[k].send(FlushQueue) 91 | except StopIteration: 92 | pass 93 | return 94 | 95 | worker = selector(msg.node) 96 | if worker: 97 | job_sink[worker].send(msg) 98 | else: 99 | default_sink.send(run_job(*msg)) 100 | 101 | for key, worker in workers.items(): 102 | t = threading.Thread( 103 | target=patch, 104 | args=(worker.source, result_queue.sink)) 105 | t.daemon = True 106 | t.start() 107 | 108 | return Connection(result_queue.source, dispatch_job) 109 | 110 | 111 | def run_hybrid(wf, selector, workers): 112 | """ 113 | Returns the result of evaluating the workflow; runs through several 114 | supplied workers in as many threads. 115 | 116 | :param wf: 117 | Workflow to compute 118 | :type wf: :py:class:`Workflow` or :py:class:`PromisedObject` 119 | 120 | :param selector: 121 | A function selecting the worker that should be run, given a hint. 122 | :param workers: 123 | A dictionary of workers 124 | 125 | :returns: 126 | result of running the workflow 127 | """ 128 | worker = hybrid_threaded_worker(selector, workers) 129 | return Scheduler().run(worker, get_workflow(wf)) 130 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Noodles documentation master file, created by 2 | sphinx-quickstart on Wed Nov 11 13:52:27 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Noodles's documentation! 7 | =================================== 8 | 9 | Introduction 10 | ------------ 11 | Often, a computer program can be sped up by executing parts of its code *in 12 | parallel* (simultaneously), as opposed to *synchronously* (one part after 13 | another). 14 | 15 | A simple example may be where you assign two variables, as follows ``a = 2 * i`` 16 | and ``b = 3 * i``. Either statement is only dependent on ``i``, but whether you 17 | assign ``a`` before ``b`` or vice versa, does not matter for how your program 18 | works. Whenever this is the case, there is potential to speed up a program, 19 | because the assignment of ``a`` and ``b`` could be done in parallel, using 20 | multiple cores on your computer's CPU. Obviously, for simple assignments like 21 | ``a = 2 * i``, there is not much time to be gained, but what if ``a`` is the 22 | result of a time-consuming function, e.g. ``a = very_difficult_function(i)``? 23 | And what if your program makes many calls to that function, e.g. ``list_of_a = 24 | [very_difficult_function(i) for i in list_of_i]``? The potential speed-up could 25 | be tremendous. 26 | 27 | So, parallel execution of computer programs is great for improving performance, 28 | but how do you tell the computer which parts should be executed in parallel, and 29 | which parts should be executed synchronously? How do you identify the order in 30 | which to execute each part, since the optimal order may be different from the 31 | order in which the parts appear in your program. These questions quickly become 32 | nearly impossible to answer as your program grows and changes during 33 | development. Because of this, many developers accept the slow execution of their 34 | program only because it saves them from the headaches associated with keeping 35 | track of which parts of their program depend on which other parts. 36 | 37 | Enter Noodles. 38 | 39 | Noodles is a Python package that can automatically construct a *callgraph* 40 | for a given Python program, listing exactly which parts depend on which parts. 41 | Moreover, Noodles can subsequently use the callgraph to execute code in parallel 42 | on your local machine using multiple cores. If you so choose, you can even 43 | configure Noodles such that it will execute the code remotely, for example on a 44 | big compute node in a cluster computer. 45 | 46 | Copyright & Licence 47 | ------------------- 48 | 49 | Noodles 0.3.0 is copyright by the *Netherlands eScience Center (NLeSC)* and released under the Apache v2 License. 50 | 51 | See http://www.esciencecenter.nl for more information on the NLeSC. 52 | 53 | Installation 54 | ------------ 55 | 56 | .. WARNING:: We don't support Python versions lower than 3.5. 57 | 58 | The core of Noodles runs on **Python 3.5** and above. To run Noodles on your own machine, no extra dependencies are required. It is advised to install Noodles in a virtualenv. If you want support for `Xenon`_, install `pyxenon`_ too. 59 | 60 | .. code-block:: bash 61 | 62 | # create the virtualenv 63 | virtualenv -p python3 64 | . /bin/activate 65 | 66 | # install noodles 67 | pip install noodles 68 | 69 | Noodles has several optional dependencies. To be able to use the Xenon job scheduler, install Noodles with:: 70 | 71 | pip install noodles[xenon] 72 | 73 | The provenance/caching feature needs TinyDB installed:: 74 | 75 | pip install noodles[prov] 76 | 77 | To be able to run the unit tests:: 78 | 79 | pip install noodles[test] 80 | 81 | Documentation Contents 82 | ====================== 83 | 84 | .. toctree:: 85 | :maxdepth: 2 86 | 87 | Introduction 88 | eating 89 | cooking 90 | tutorials 91 | implementation 92 | 93 | 94 | Indices and tables 95 | ================== 96 | 97 | * :ref:`genindex` 98 | * :ref:`modindex` 99 | * :ref:`search` 100 | 101 | .. _Xenon: http://nlesc.github.io/Xenon/ 102 | .. _pyxenon: http://github.com/NLeSC/pyxenon 103 | .. _`generating SSH keys`: https://help.github.com/articles/generating-ssh-keys/ 104 | .. _`decorators`: https://www.thecodeship.com/patterns/guide-to-python-function-decorators/ 105 | --------------------------------------------------------------------------------