Repository: construct/construct

Issue:
Using custom functions in Rebuild
This minimal test shows how using a custom function to rebuild fails in the compiled case, but works in the interpreted case.

```python
def getlen(this):
    return 2

template = Struct("count" / Rebuild(Byte, getlen), "my_items" / Byte[this.count])
for d  in [template, template.compile()]:
    assert d.parse(b"\x02ab") == Container(count=2, my_items=[97,98])
    assert d.build(dict(count=None,my_items=[255,255])) == b"\x02\xff\xff"
    assert d.build(dict(count=2,my_items=[255,255])) == b"\x02\xff\xff"
    assert d.build(dict(my_items=[255,255])) == b"\x02\xff\xff"
```

Hints from issue comments:
@franzhaas: I learned more about how to use the "this" mechanics. If you encounter this problem, check if you can make do without a function, just a this expression. In my case it was possible.

I find it more readable, the compiled code is faster...

I still believe that this improvement is worth it. Alternatively, we can detect the situation and raise a helpful Exception. 

Solution patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index b69943f9..8857958f 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -23,7 +23,7 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install pytest pytest-benchmark numpy arrow ruamel.yaml cloudpickle lz4
+          pip install "pytest<8.0.0" pytest-benchmark numpy arrow ruamel.yaml cloudpickle lz4
       - name: Install cryptography (but not for pypy on windows)
         if: ${{ !((matrix.os == 'windows-latest') && (matrix.python-version == 'pypy3.9')) }}
         run: |
diff --git a/construct/core.py b/construct/core.py
index f56ca163..8f5919f0 100644
--- a/construct/core.py
+++ b/construct/core.py
@@ -267,6 +267,7 @@ def __init__(self):
         self.linkedinstances = {}
         self.linkedparsers = {}
         self.linkedbuilders = {}
+        self.userfunction = {}
 
     def allocateId(self):
         self.nextid += 1
@@ -535,6 +536,7 @@ def reuse(obj, func):
             linkedinstances = {}
             linkedparsers = {}
             linkedbuilders = {}
+            userfunction = {}
 
             len_ = len
             sum_ = sum
@@ -564,6 +566,7 @@ def buildall(obj, io, this):
         module.linkedinstances = code.linkedinstances
         module.linkedparsers = code.linkedparsers
         module.linkedbuilders = code.linkedbuilders
+        module.userfunction = code.userfunction
         compiled = module.compiled
         compiled.source = source
         compiled.module = module
@@ -3007,7 +3010,12 @@ def _emitparse(self, code):
         return self.subcon._compileparse(code)
 
     def _emitbuild(self, code):
-        return f"reuse({repr(self.func)}, lambda obj: ({self.subcon._compilebuild(code)}))"
+        if isinstance(self.func, ExprMixin) or (not callable(self.func)):
+            return f"reuse({repr(self.func)}, lambda obj: ({self.subcon._compilebuild(code)}))"
+        else:
+            aid = code.allocateId()
+            code.userfunction[aid] = self.func
+            return f"reuse(userfunction[{aid}](this), lambda obj: ({self.subcon._compilebuild(code)}))"
 
     def _emitseq(self, ksy, bitwise):
         return self.subcon._compileseq(ksy, bitwise)
diff --git a/construct/lib/containers.py b/construct/lib/containers.py
index 570516c7..66b6ef98 100644
--- a/construct/lib/containers.py
+++ b/construct/lib/containers.py
@@ -56,6 +56,31 @@ def wrapper(self, *args, **kw):
     return decorator
 
 
+def value_to_string(value):
+    if value.__class__.__name__ == "EnumInteger":
+        return "(enum) (unknown) %s" % (value, )
+
+    if value.__class__.__name__ == "EnumIntegerString":
+        return "(enum) %s %s" % (value, value.intvalue, )
+
+    if value.__class__.__name__ in ["HexDisplayedBytes", "HexDumpDisplayedBytes"]:
+        return str(value)
+
+    if isinstance(value, bytes):
+        printingcap = 16
+        if len(value) <= printingcap or globalPrintFullStrings:
+            return "%s (total %d)" % (repr(value), len(value))
+        return "%s... (truncated, total %d)" % (repr(value[:printingcap]), len(value))
+
+    if isinstance(value, str):
+        printingcap = 32
+        if len(value) <= printingcap or globalPrintFullStrings:
+            return "%s (total %d)" % (repr(value), len(value))
+        return "%s... (truncated, total %d)" % (repr(value[:printingcap]), len(value))
+
+    return str(value)
+
+
 class Container(dict):
     # NOTE: be careful when working with these objects. Any method can be shadowed, so instead of doing `self.items()` you should do `dict.items(self)`. Operation that use methods implicitly (such as `x in self` or `self[k]`) will work as usual.
     r"""
@@ -143,27 +168,7 @@ def __str__(self, /):
                 continue
             if isflags and not v and not globalPrintFalseFlags:
                 continue
-            text.extend([indentation, str(k), " = "])
-            if v.__class__.__name__ == "EnumInteger":
-                text.append("(enum) (unknown) %s" % (v, ))
-            elif v.__class__.__name__ == "EnumIntegerString":
-                text.append("(enum) %s %s" % (v, v.intvalue, ))
-            elif v.__class__.__name__ in ["HexDisplayedBytes", "HexDumpDisplayedBytes"]:
-                text.append(indentation.join(str(v).split("\n")))
-            elif isinstance(v, bytes):
-                printingcap = 16
-                if len(v) <= printingcap or globalPrintFullStrings:
-                    text.append("%s (total %d)" % (repr(v), len(v)))
-                else:
-                    text.append("%s... (truncated, total %d)" % (repr(v[:printingcap]), len(v)))
-            elif isinstance(v, str):
-                printingcap = 32
-                if len(v) <= printingcap or globalPrintFullStrings:
-                    text.append("%s (total %d)" % (repr(v), len(v)))
-                else:
-                    text.append("%s... (truncated, total %d)" % (repr(v[:printingcap]), len(v)))
-            else:
-                text.append(indentation.join(str(v).split("\n")))
+            text.extend([indentation, str(k), " = ", indentation.join(value_to_string(v).split("\n"))])
         return "".join(text)
 
     def _search(self, compiled_pattern, search_all, /):
@@ -249,7 +254,7 @@ def __str__(self, /):
         text = ["ListContainer: "]
         for k in self:
             text.append(indentation)
-            lines = str(k).split("\n")
+            lines = value_to_string(k).split("\n")
             text.append(indentation.join(lines))
         return "".join(text)
 
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000..f5a52af7
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+pythonpath = .
+


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 779bd816..eb7068fb 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -641,6 +641,18 @@ def test_rebuild_issue_664():
     # no asserts are needed
     d.build(obj)
 
+
+def test_rebuild_custom_function():
+    def getlen(this):
+        return 2
+
+    template = Struct(      "count" / Rebuild(Byte, getlen), "my_items" / Byte[this.count])
+    for d  in [template, template.compile()]:
+        assert d.parse(b"\x02ab") == Container(count=2, my_items=[97,98])
+        assert d.build(dict(count=None,my_items=[255,255])) == b"\x02\xff\xff"
+        assert d.build(dict(count=2,my_items=[255,255])) == b"\x02\xff\xff"
+        assert d.build(dict(my_items=[255,255])) == b"\x02\xff\xff"
+
 def test_default():
     d = Default(Byte, 0)
     common(d, b"\xff", 255, 1)