Commits: 11

Fix Python interpreter: leading coma when 0 args

When giving a hint to implement a step with no parameters, the generated code would contain a leading coma, like this:

@step("The following table maps words to their length")
def step_implementation_07(, **kwargs):
    assert "cat" == "dog", "Obviously you need to replace this!"

Now it's smarter.

index 484bdc3..594dc1a 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -89,7 +89,7 @@ def ready():
=            else:
=                # Generate helpful hint
=                indices = re.findall(r"\{(\d)\}", variant)
-                params = map(lambda index: f"arg_{index}: str", indices)
+                params = list(map(lambda index: f"arg_{index}: str", indices))
=                name = f"step_implementation_{len(steps_implementation):02}"
=
=                hint = dedent(f"""
@@ -97,7 +97,7 @@ def ready():
=
=                ``` python
=                @step("{variant}")
-                def {name}({str.join(", ", params)}, **kwargs):
+                def {name}({str.join(", ", params + [ "**kwargs" ])}):
=                    assert "cat" == "dog", "Obviously you need to replace this!"
=                ```
=                """)

Implement support for tables

index c347baa..6536f82 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -36,6 +36,32 @@ Content outside of bullet points (like this) won't have any effect on the progra
=    ```
=
=  * There are `3` `r`s in the word `strawberry`
+  * The following table maps words to their lengths
+  
+    | word   | length |
+    |--------|--------|
+    | cat    | 3      |
+    | stork  | 5      |
+    | rabbit | 6      |
+    | snake  | 5      |
+    | minx   | 4      |
+
+    Any table within a step will be passed to the interpreter as a 2d array, like so:
+    
+    ``` json
+    [
+        [ "word"   , "length" ],
+        [ "cat"    , "3"      ],
+        [ "stork"  , "5"      ],
+        [ "rabbit" , "6"      ],
+        [ "snake"  , "5"      ],
+        [ "minx"   , "4"      ]
+    ]
+    ```
+
+    Note that heading row is not treated in any special way, and all cells are strings. It's up to an interpreter to deal with it.
+   
+   
=  * The reverse of `abc` is `cba`
=  * The reverse of `CIA` is `KGB`
=
index 594dc1a..4d16408 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -164,4 +164,13 @@ def step_implementation_06(word: str, reverse: str, **kwargs):
=    actual_reverse = word[::-1]
=    tester.assertEqual(reverse, actual_reverse)
=
+@step("The following table maps words to their lengths")
+def step_implementation_07(**kwargs):
+    for table in kwargs["tables"]:
+        # Skip the first row - it's a heading
+        for [word, length] in table[1:]:
+            actual_length = len(word)
+            tester.assertEqual(actual_length, int(length), f"the length of {word=}")
+
+
=ready()
index 8788fab..550d8c0 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -94,6 +94,9 @@ pub struct Step {
=
=    /// List of code blocks
=    pub code_blocks: Vec<CodeBlock>,
+
+    /// List of tables
+    pub tables: Vec<Table>,
=}
=
=#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -113,6 +116,39 @@ impl From<&markdown::mdast::Code> for CodeBlock {
=    }
=}
=
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct Table(Vec<Vec<String>>);
+
+impl From<&markdown::mdast::Table> for Table {
+    fn from(table: &markdown::mdast::Table) -> Self {
+        let rows: Vec<Vec<String>> = table
+            .children
+            .iter()
+            .filter_map(|child| {
+                let markdown::mdast::Node::TableRow(_) = child else {
+                    return None;
+                };
+                let rows: Vec<String> = child
+                    .children()
+                    .cloned()
+                    .unwrap_or_default()
+                    .iter()
+                    .filter_map(|grandchild| {
+                        let markdown::mdast::Node::TableCell(_) = grandchild else {
+                            return None;
+                        };
+                        grandchild.to_string().into()
+                    })
+                    .collect();
+
+                Some(rows)
+            })
+            .collect();
+
+        Self(rows)
+    }
+}
+
=#[derive(Deserialize)]
=pub struct FrontMatter {
=    pub interpreter: String,
@@ -125,6 +161,7 @@ impl Suite {
=            &markdown::ParseOptions {
=                constructs: markdown::Constructs {
=                    frontmatter: true,
+                    gfm_table: true,
=                    ..Default::default()
=                },
=                ..Default::default()
@@ -263,6 +300,7 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=
=        // TODO: Extract list, tables, code blocks etc. from subsequent children of the list item
=        let mut code_blocks = vec![];
+        let mut tables = vec![];
=
=        for child in item.children.iter() {
=            match child {
@@ -276,6 +314,11 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=                    code_blocks.push(CodeBlock::from(code))
=                }
=
+                markdown::mdast::Node::Table(table) => {
+                    log::info!("Found a table");
+                    tables.push(Table::from(table));
+                }
+
=                _ => continue,
=            }
=        }
@@ -285,6 +328,7 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=            variant: variant_headline.to_string(),
=            arguments,
=            code_blocks,
+            tables,
=        })
=    }
=}

Silence some noisy logging

index 550d8c0..5bf24d7 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -305,7 +305,7 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=        for child in item.children.iter() {
=            match child {
=                markdown::mdast::Node::Code(code) => {
-                    log::info!(
+                    log::debug!(
=                        "Found a code block: {language} ({meta})",
=                        language = code.lang.as_deref().unwrap_or("unspecified"),
=                        meta = code.meta.as_deref().unwrap_or_default(),
@@ -315,7 +315,7 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=                }
=
=                markdown::mdast::Node::Table(table) => {
-                    log::info!("Found a table");
+                    log::debug!("Found a table");
=                    tables.push(Table::from(table));
=                }
=

Update the roadmap

index 142bd8a..bc73be3 100644
--- a/README.md
+++ b/README.md
@@ -39,13 +39,14 @@ Support for Linux, OS X and Web (WASM).
=  - [ ] A summary at the bottom (esp. list of errors)
=  - [x] Use colors
=  - [ ] More instructive error messages
-  - [ ] Indent multiline error messages
+  - [x] Indent multiline error messages
=- [ ] Pass more step data to interpreters
-    - [ ] Code blocks
+    - [x] Code blocks
+    - [x] Tables
=    - [ ] Lists
-    - [ ] Tables
=    - [ ] Definition lists
=    - [ ] Original markdown fragment
+    - [ ] Block quotes
=- [x] Nix package (from Flake)
=- [ ] Use for evaluating Jewiet's Form to Mail specification
=- [ ] Helper libraries
@@ -56,6 +57,7 @@ Support for Linux, OS X and Web (WASM).
=      - [x] Insightful assertion errors
=      - [ ] Split library code from the basic interpreter
=    - [ ] for Clojure
+    - [ ] For POSIX shells
=- [ ] Capture more data in reports
=  - [ ] Attachments (screenshots, videos, datasets, etc.)
=  - [ ] Performance data (interpreters' startup times, steps' durations)

Bump the minor version

To celebrate tables support.

index c2e992f..a4b37ac 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ dependencies = [
=
=[[package]]
=name = "tad-better-behavior"
-version = "0.2.0"
+version = "0.3.0"
=dependencies = [
= "anyhow",
= "clap",
index 9297cf6..32ebb5f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
=[package]
=name = "tad-better-behavior"
-version = "0.2.0"
+version = "0.3.0"
=edition = "2024"
=
=[dependencies]

Promise to support BSD

For Daniel.

index bc73be3..3f727ab 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ and even the original markdown fragment itself.
=
=## Cross platform
=
-Support for Linux, OS X and Web (WASM).
+Support for Linux, BSD, OS X and Web (WASM).
=
=
=# Roadmap

Separate library code from basic.py to tbb.py

Write docstrings for public functions (register_step, step and ready).

index 4d16408..83e57d1 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -1,123 +1,13 @@
=#!/usr/bin/env python3
=
-import inspect
=import json
-import logging
-import os
-import re
=import unittest
-from textwrap import dedent
-from typing import Any, Callable
-
-
-# Library code
-# ============
-
-# Setup logging
-log_level = os.environ.get("tbb_interpreter_log", "warn").upper()
-logging.basicConfig(level=getattr(logging, log_level))
-log = logging.getLogger("basic_interpreter")
-
-def send(message):
-    serialized = json.dumps(message)
-    log.debug("Sending: %s", serialized)
-    print (serialized)
-
-# This will hold all step implementations
-steps_implementation = dict()
-
-# A helper to register a step implementation
-def register_step(variant: str, implementation: Callable[..., Any]):
-    log.debug("Registering step implementation for '%s'", variant)
-    steps_implementation[variant] = implementation
-
-# A decorator to register a step implementation
-def step(variant: str) -> Callable[..., Any]:
-    def decorator(implementation: Callable[..., Any]) -> Callable[..., Any]:
-        register_step(variant, implementation)
-        
-        # We don't really do anything to the implementation itself
-        return implementation
-
-    return decorator
-
-# Call this when ready, i.e. all implementations are registered
-def ready():
-    send({
-        "type": "InterpreterState",
-        "ready": True
-    })
-
-    # Loop over input
-    while True:
-        log.debug("Awaiting message...")
-
-        try:
-            received = input()
-            log.debug("Received: %s", received)
-            message = json.loads(received)
-            if not message["type"] == "Execute":
-                log.warning(f"Unexpected message received from the control program. Expected Execute type message, got {received}")
-                continue
-
-            step = message["step"] # The only message variant we expect
-            variant = step.get("variant")
-            arguments = step.get("arguments")
-            log.debug(f"Looking for implementation of '{ variant }'")
-            implementation = steps_implementation.get(variant)
-
-            if implementation:
-                log.debug(f"Found an implementation of '{ variant }'")
-                try:
-                    signature = inspect.signature(implementation)
-                    log.debug(f"Signature: {signature}")
-                    for index, key in enumerate(signature.parameters):
-                        parameter = signature.parameters[key]
-                        log.debug(f"Processing parameter {index} ({parameter.annotation})")
-                        if parameter.annotation == inspect._empty:
-                            log.debug (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
-                            continue 
-
-                        log.debug (f"Converting argument {index} to {parameter.annotation.__name__}")
-                        arguments[index] = parameter.annotation(arguments[index])
-
-                    implementation(*arguments, **step)
-                    send({ "type": 'Success' })
-                except Exception as error:
-                    send({ "type": "Failure", "reason": str(error) })
-
-            else:
-                # Generate helpful hint
-                indices = re.findall(r"\{(\d)\}", variant)
-                params = list(map(lambda index: f"arg_{index}: str", indices))
-                name = f"step_implementation_{len(steps_implementation):02}"
-
-                hint = dedent(f"""
-                To get started, place the following in {__file__}:
-
-                ``` python
-                @step("{variant}")
-                def {name}({str.join(", ", params + [ "**kwargs" ])}):
-                    assert "cat" == "dog", "Obviously you need to replace this!"
-                ```
-                """)
-
-                log.warning(f"Not implemented: {variant}")
-                send({
-                    "type": 'Failure',
-                    "reason": "step not implemented",
-                    "hint": hint
-                })
-
-        except EOFError:
-            # The control program closed the stream.
-            # Most likely it indicates the end of scenario.
-            break
-
-
-# User code
-# =========
=
+import tbb
+from tbb import step, log
+
+
+# Nice assertions with helpful error messages
=tester = unittest.TestCase()
=
=@step("Add {0} and {1} to get {2}")
@@ -173,4 +63,4 @@ def step_implementation_07(**kwargs):
=            tester.assertEqual(actual_length, int(length), f"the length of {word=}")
=
=
-ready()
+tbb.ready()
new file mode 100644
index 0000000..0cbb852
--- /dev/null
+++ b/samples/tbb.py
@@ -0,0 +1,160 @@
+"""Reusable code useful to implement Python TBB interpreters.
+
+See `basic.py` for example.
+"""
+
+import inspect
+import json
+import logging
+import os
+import re
+from textwrap import dedent
+from typing import Any, Callable
+
+
+# Setup logging
+log_level = os.environ.get("tbb_interpreter_log", "warn").upper()
+logging.basicConfig(level=getattr(logging, log_level))
+log = logging.getLogger("basic_interpreter")
+
+def send(message):
+    serialized = json.dumps(message)
+    log.debug("Sending: %s", serialized)
+    print (serialized)
+
+# This will hold all step implementations
+steps_implementation = dict()
+
+def register_step(variant: str, implementation: Callable[..., Any]):
+    """A helper to register a step implementation
+
+    You probably want to use @step decorator instead.
+    """
+    log.debug("Registering step implementation for '%s'", variant)
+    steps_implementation[variant] = implementation
+
+def step(variant: str) -> Callable[..., Any]:
+    """ A decorator to register a step implementation
+
+    Each step has a variant, which is the text from the first line of the list
+    item, with arguments removed, e.g.
+
+    ``` markdown
+    * Make `coffee` for `2` people
+    ```
+
+    has a variant of `Make {0} for {1} people`. You can register an
+    implementation for this variant like so:
+
+    ``` python
+    @step("Make {0} for {1} people")
+    def serve(what, patron_count: int):
+        # Do what you got to do...
+    ```
+
+    We called this function serve, because another step my be:
+
+
+    ``` markdown
+    * Make `cheesecake` for `8` people
+    ```
+
+    The two steps will share the variant, and thus the same implementation.
+    Notice that our function has type annotation on the second argument
+    (patron_count), but not the first Notice that our function has type
+    annotation on the second argument (`patron_count`), but not the first.
+    That's because by default all arguments will be passed as `str`. If you
+    provide an annotation, the value will be parsed from that string.
+
+    BTW, this decorator is a thin wrapper around the `register_step` function.
+    You can use that instead, for example if you don't like decorators.
+    """
+    def decorator(implementation: Callable[..., Any]) -> Callable[..., Any]:
+        register_step(variant, implementation)
+        
+        # We don't really do anything to the implementation itself
+        return implementation
+
+    return decorator
+
+def ready():
+    """Call this when your interpreter is ready.
+
+    Before calling this function make sure all step implementations are
+    registered (using the `@step` decorator) and everything is set up (e.g. you
+    might want to setup some initial state, start some processes, etc).
+
+    This function will block until the spec is evaluated. Put all the cleanup
+    code (e.g. close any process previously started) after this function call.
+    """
+
+    send({
+        "type": "InterpreterState",
+        "ready": True
+    })
+
+    # Loop over input
+    while True:
+        log.debug("Awaiting message...")
+
+        try:
+            received = input()
+            log.debug("Received: %s", received)
+            message = json.loads(received)
+            if not message["type"] == "Execute":
+                log.warning(f"Unexpected message received from the control program. Expected Execute type message, got {received}")
+                continue
+
+            step = message["step"] # The only message variant we expect
+            variant = step.get("variant")
+            arguments = step.get("arguments")
+            log.debug(f"Looking for implementation of '{ variant }'")
+            implementation = steps_implementation.get(variant)
+
+            if implementation:
+                log.debug(f"Found an implementation of '{ variant }'")
+                try:
+                    signature = inspect.signature(implementation)
+                    log.debug(f"Signature: {signature}")
+                    for index, key in enumerate(signature.parameters):
+                        parameter = signature.parameters[key]
+                        log.debug(f"Processing parameter {index} ({parameter.annotation})")
+                        if parameter.annotation == inspect._empty:
+                            log.debug (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
+                            continue 
+
+                        log.debug (f"Converting argument {index} to {parameter.annotation.__name__}")
+                        arguments[index] = parameter.annotation(arguments[index])
+
+                    implementation(*arguments, **step)
+                    send({ "type": 'Success' })
+                except Exception as error:
+                    send({ "type": "Failure", "reason": str(error) })
+
+            else:
+                # Generate helpful hint
+                indices = re.findall(r"\{(\d)\}", variant)
+                params = list(map(lambda index: f"arg_{index}: str", indices))
+                name = f"step_implementation_{len(steps_implementation):02}"
+
+                hint = dedent(f"""
+                To get started, place the following in {__file__}:
+
+                ``` python
+                @step("{variant}")
+                def {name}({str.join(", ", params + [ "**kwargs" ])}):
+                    assert "cat" == "dog", "Obviously you need to replace this!"
+                ```
+                """)
+
+                log.warning(f"Not implemented: {variant}")
+                send({
+                    "type": 'Failure',
+                    "reason": "step not implemented",
+                    "hint": hint
+                })
+
+        except EOFError:
+            # The control program closed the stream.
+            # Most likely it indicates the end of scenario.
+            break

Fix tbb.py: wrong path in the implementation hint

index 0cbb852..2a62344 100644
--- a/samples/tbb.py
+++ b/samples/tbb.py
@@ -137,8 +137,12 @@ def ready():
=                params = list(map(lambda index: f"arg_{index}: str", indices))
=                name = f"step_implementation_{len(steps_implementation):02}"
=
+                import importlib
+                main_module = importlib.import_module("__main__")
+                main_path = getattr(main_module, "__file__", "your interpreter script")
+
=                hint = dedent(f"""
-                To get started, place the following in {__file__}:
+                To get started, place the following code in {main_path}:
=
=                ``` python
=                @step("{variant}")

Format python code, clarify a comment

index 83e57d1..11158dd 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -16,14 +16,12 @@ def add_and_verify(a: float, b: float, expected: float, **kwargs):
=
=    tester.assertEqual(expected, a + b)
=
-
=@step("Divide {0} by {1} to get {2}")
=def divide_and_verify(a: float, b: float, expected: float, **kwargs):
=    log.debug(f"{ a } / { b } = { expected }?")
=    
=    tester.assertAlmostEqual (expected, a / b)
=
-
=@step("The word {0} has {1} characters")
=def verify_characters_count(word, expected_length: int, **kwargs):
=    log.debug(f"Is '{word}' of length {expected_length}?")
@@ -31,7 +29,6 @@ def verify_characters_count(word, expected_length: int, **kwargs):
=
=    tester.assertEqual(expected_length, actual_length)
=
-
=@step("There are {0} properties in the following JSON")
=def count_json_properties(expected_count: int, **kwargs):
=    code = kwargs['code_blocks'][0]['value']
@@ -43,7 +40,6 @@ def count_json_properties(expected_count: int, **kwargs):
=def step_implementation_04(a: float, b: float, expected: float, **kwargs):
=    tester.assertAlmostEqual(expected, b - a) 
=
-
=@step("There are {0} {1}s in the word {2}")
=def step_implementation_05(count: int, letter: str, word: str, **kwargs):
=    actual_count = word.count(letter)
index 2a62344..76d7073 100644
--- a/samples/tbb.py
+++ b/samples/tbb.py
@@ -11,12 +11,12 @@ import re
=from textwrap import dedent
=from typing import Any, Callable
=
-
=# Setup logging
=log_level = os.environ.get("tbb_interpreter_log", "warn").upper()
=logging.basicConfig(level=getattr(logging, log_level))
=log = logging.getLogger("basic_interpreter")
=
+
=def send(message):
=    serialized = json.dumps(message)
=    log.debug("Sending: %s", serialized)
@@ -161,4 +161,5 @@ def ready():
=        except EOFError:
=            # The control program closed the stream.
=            # Most likely it indicates the end of scenario.
+            # Return control to the interpreter for any cleanup.
=            break

Tell git to ignore pycache

index 727883b..481d8a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,9 @@
=.direnv/
=result
=
+# For python scripting
+__pycache__
+
=# Added by cargo
=
=/target

Mark python library as done

index 3f727ab..d0041c3 100644
--- a/README.md
+++ b/README.md
@@ -50,12 +50,12 @@ Support for Linux, BSD, OS X and Web (WASM).
=- [x] Nix package (from Flake)
=- [ ] Use for evaluating Jewiet's Form to Mail specification
=- [ ] Helper libraries
-    - [ ] for Python
+    - [x] for Python
=      - [x] Proof-of-concept
=      - [x] `@step` decorator
=      - [x] Automatic arguments conversion (casting)
=      - [x] Insightful assertion errors
-      - [ ] Split library code from the basic interpreter
+      - [x] Split library code from the basic interpreter
=    - [ ] for Clojure
=    - [ ] For POSIX shells
=- [ ] Capture more data in reports