You are viewing a plain text version of this content. The canonical link for it is here.
Posted to cvs@httpd.apache.org by ic...@apache.org on 2023/04/27 11:35:51 UTC

svn commit: r1909452 - in /httpd/httpd/trunk/test: modules/http1/htdocs/cgi/ modules/http2/ modules/http2/htdocs/cgi/ pyhttpd/

Author: icing
Date: Thu Apr 27 11:35:51 2023
New Revision: 1909452

URL: http://svn.apache.org/viewvc?rev=1909452&view=rev
Log:
make the http2 test suite working again


Added:
    httpd/httpd/trunk/test/modules/http2/htdocs/cgi/requestparser.py
Modified:
    httpd/httpd/trunk/test/modules/http1/htdocs/cgi/upload.py
    httpd/httpd/trunk/test/modules/http2/htdocs/cgi/echohd.py
    httpd/httpd/trunk/test/modules/http2/htdocs/cgi/env.py
    httpd/httpd/trunk/test/modules/http2/htdocs/cgi/hecho.py
    httpd/httpd/trunk/test/modules/http2/htdocs/cgi/mnot164.py
    httpd/httpd/trunk/test/modules/http2/htdocs/cgi/necho.py
    httpd/httpd/trunk/test/modules/http2/htdocs/cgi/upload.py
    httpd/httpd/trunk/test/modules/http2/test_003_get.py
    httpd/httpd/trunk/test/modules/http2/test_004_post.py
    httpd/httpd/trunk/test/modules/http2/test_200_header_invalid.py
    httpd/httpd/trunk/test/pyhttpd/nghttp.py
    httpd/httpd/trunk/test/pyhttpd/result.py

Modified: httpd/httpd/trunk/test/modules/http1/htdocs/cgi/upload.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http1/htdocs/cgi/upload.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http1/htdocs/cgi/upload.py (original)
+++ httpd/httpd/trunk/test/modules/http1/htdocs/cgi/upload.py Thu Apr 27 11:35:51 2023
@@ -29,9 +29,9 @@ def get_request_params():
                 oforms[name] = values[0]
         elif ctype.startswith("multipart/"):
             def on_field(field):
-                oforms[field.field_name] = field.value
+                oforms[field.field_name.decode()] = field.value.decode()
             def on_file(file):
-                ofiles[field.field_name] = field.value
+                ofiles[file.field_name.decode()] = file.value
             multipart.parse_form(headers={"Content-Type": ctype}, input_stream=sys.stdin.buffer, on_field=on_field, on_file=on_file)
     return oforms, ofiles
 

Modified: httpd/httpd/trunk/test/modules/http2/htdocs/cgi/echohd.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/htdocs/cgi/echohd.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/htdocs/cgi/echohd.py (original)
+++ httpd/httpd/trunk/test/modules/http2/htdocs/cgi/echohd.py Thu Apr 27 11:35:51 2023
@@ -1,29 +1,6 @@
 #!/usr/bin/env python3
 import os, sys
-from urllib import parse
-import multipart # https://github.com/andrew-d/python-multipart (`apt install python3-multipart`)
-
-
-def get_request_params():
-    oforms = {}
-    ofiles = {}
-    if "REQUEST_URI" in os.environ:
-        qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
-        for name, values in qforms.items():
-            oforms[name] = values[0]
-    if "HTTP_CONTENT_TYPE" in os.environ:
-        ctype = os.environ["HTTP_CONTENT_TYPE"]
-        if ctype == "application/x-www-form-urlencoded":
-            qforms = parse.parse_qs(parse.urlsplit(sys.stdin.read()).query)
-            for name, values in qforms.items():
-                oforms[name] = values[0]
-        elif ctype.startswith("multipart/"):
-            def on_field(field):
-                oforms[field.field_name] = field.value
-            def on_file(file):
-                ofiles[field.field_name] = field.value
-            multipart.parse_form(headers={"Content-Type": ctype}, input_stream=sys.stdin.buffer, on_field=on_field, on_file=on_file)
-    return oforms, ofiles
+from requestparser import get_request_params
 
 
 forms, files = get_request_params()

Modified: httpd/httpd/trunk/test/modules/http2/htdocs/cgi/env.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/htdocs/cgi/env.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/htdocs/cgi/env.py (original)
+++ httpd/httpd/trunk/test/modules/http2/htdocs/cgi/env.py Thu Apr 27 11:35:51 2023
@@ -1,29 +1,6 @@
 #!/usr/bin/env python3
 import os, sys
-from urllib import parse
-import multipart # https://github.com/andrew-d/python-multipart (`apt install python3-multipart`)
-
-
-def get_request_params():
-    oforms = {}
-    ofiles = {}
-    if "REQUEST_URI" in os.environ:
-        qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
-        for name, values in qforms.items():
-            oforms[name] = values[0]
-    if "HTTP_CONTENT_TYPE" in os.environ:
-        ctype = os.environ["HTTP_CONTENT_TYPE"]
-        if ctype == "application/x-www-form-urlencoded":
-            qforms = parse.parse_qs(parse.urlsplit(sys.stdin.read()).query)
-            for name, values in qforms.items():
-                oforms[name] = values[0]
-        elif ctype.startswith("multipart/"):
-            def on_field(field):
-                oforms[field.field_name] = field.value
-            def on_file(file):
-                ofiles[field.field_name] = field.value
-            multipart.parse_form(headers={"Content-Type": ctype}, input_stream=sys.stdin.buffer, on_field=on_field, on_file=on_file)
-    return oforms, ofiles
+from requestparser import get_request_params
 
 
 forms, files = get_request_params()

Modified: httpd/httpd/trunk/test/modules/http2/htdocs/cgi/hecho.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/htdocs/cgi/hecho.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/htdocs/cgi/hecho.py (original)
+++ httpd/httpd/trunk/test/modules/http2/htdocs/cgi/hecho.py Thu Apr 27 11:35:51 2023
@@ -1,29 +1,6 @@
 #!/usr/bin/env python3
 import os, sys
-from urllib import parse
-import multipart # https://github.com/andrew-d/python-multipart (`apt install python3-multipart`)
-
-
-def get_request_params():
-    oforms = {}
-    ofiles = {}
-    if "REQUEST_URI" in os.environ:
-        qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
-        for name, values in qforms.items():
-            oforms[name] = values[0]
-    if "HTTP_CONTENT_TYPE" in os.environ:
-        ctype = os.environ["HTTP_CONTENT_TYPE"]
-        if ctype == "application/x-www-form-urlencoded":
-            qforms = parse.parse_qs(parse.urlsplit(sys.stdin.read()).query)
-            for name, values in qforms.items():
-                oforms[name] = values[0]
-        elif ctype.startswith("multipart/"):
-            def on_field(field):
-                oforms[field.field_name] = field.value
-            def on_file(file):
-                ofiles[field.field_name] = field.value
-            multipart.parse_form(headers={"Content-Type": ctype}, input_stream=sys.stdin.buffer, on_field=on_field, on_file=on_file)
-    return oforms, ofiles
+from requestparser import get_request_params
 
 
 forms, files = get_request_params()

Modified: httpd/httpd/trunk/test/modules/http2/htdocs/cgi/mnot164.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/htdocs/cgi/mnot164.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/htdocs/cgi/mnot164.py (original)
+++ httpd/httpd/trunk/test/modules/http2/htdocs/cgi/mnot164.py Thu Apr 27 11:35:51 2023
@@ -1,29 +1,6 @@
 #!/usr/bin/env python3
 import os, sys
-from urllib import parse
-import multipart # https://github.com/andrew-d/python-multipart (`apt install python3-multipart`)
-
-
-def get_request_params():
-    oforms = {}
-    ofiles = {}
-    if "REQUEST_URI" in os.environ:
-        qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
-        for name, values in qforms.items():
-            oforms[name] = values[0]
-    if "HTTP_CONTENT_TYPE" in os.environ:
-        ctype = os.environ["HTTP_CONTENT_TYPE"]
-        if ctype == "application/x-www-form-urlencoded":
-            qforms = parse.parse_qs(parse.urlsplit(sys.stdin.read()).query)
-            for name, values in qforms.items():
-                oforms[name] = values[0]
-        elif ctype.startswith("multipart/"):
-            def on_field(field):
-                oforms[field.field_name] = field.value
-            def on_file(file):
-                ofiles[field.field_name] = field.value
-            multipart.parse_form(headers={"Content-Type": ctype}, input_stream=sys.stdin.buffer, on_field=on_field, on_file=on_file)
-    return oforms, ofiles
+from requestparser import get_request_params
 
 
 forms, files = get_request_params()

Modified: httpd/httpd/trunk/test/modules/http2/htdocs/cgi/necho.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/htdocs/cgi/necho.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/htdocs/cgi/necho.py (original)
+++ httpd/httpd/trunk/test/modules/http2/htdocs/cgi/necho.py Thu Apr 27 11:35:51 2023
@@ -1,30 +1,7 @@
 #!/usr/bin/env python3
 import time
 import os, sys
-from urllib import parse
-import multipart # https://github.com/andrew-d/python-multipart (`apt install python3-multipart`)
-
-
-def get_request_params():
-    oforms = {}
-    ofiles = {}
-    if "REQUEST_URI" in os.environ:
-        qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
-        for name, values in qforms.items():
-            oforms[name] = values[0]
-    if "HTTP_CONTENT_TYPE" in os.environ:
-        ctype = os.environ["HTTP_CONTENT_TYPE"]
-        if ctype == "application/x-www-form-urlencoded":
-            qforms = parse.parse_qs(parse.urlsplit(sys.stdin.read()).query)
-            for name, values in qforms.items():
-                oforms[name] = values[0]
-        elif ctype.startswith("multipart/"):
-            def on_field(field):
-                oforms[field.field_name] = field.value
-            def on_file(file):
-                ofiles[field.field_name] = field.value
-            multipart.parse_form(headers={"Content-Type": ctype}, input_stream=sys.stdin.buffer, on_field=on_field, on_file=on_file)
-    return oforms, ofiles
+from requestparser import get_request_params
 
 
 forms, files = get_request_params()
@@ -63,11 +40,12 @@ Content-Type: text/html\n
     <p>No count was specified: %s</p>
     </body></html>""" % (count))
 
-except KeyError:
+except KeyError as ex:
     print("Status: 200 Ok")
-    print("""\
+    print(f"""\
 Content-Type: text/html\n
-    <html><body>
+    <html><body>uri: uri={os.environ['REQUEST_URI']} ct={os.environ['CONTENT_TYPE']} ex={ex}
+    forms={forms}
     Echo <form method="POST" enctype="application/x-www-form-urlencoded">
     <input type="text" name="count">
     <input type="text" name="text">

Added: httpd/httpd/trunk/test/modules/http2/htdocs/cgi/requestparser.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/htdocs/cgi/requestparser.py?rev=1909452&view=auto
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/htdocs/cgi/requestparser.py (added)
+++ httpd/httpd/trunk/test/modules/http2/htdocs/cgi/requestparser.py Thu Apr 27 11:35:51 2023
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+import os
+import sys
+from urllib import parse
+import multipart # https://github.com/andrew-d/python-multipart (`apt install python3-multipart`)
+import shutil
+
+
+try:  # Windows needs stdio set for binary mode.
+    import msvcrt
+
+    msvcrt.setmode(0, os.O_BINARY)  # stdin  = 0
+    msvcrt.setmode(1, os.O_BINARY)  # stdout = 1
+except ImportError:
+    pass
+
+
+class FileItem:
+
+    def __init__(self, mparse_item):
+        self.item = mparse_item
+
+    @property
+    def file_name(self):
+        return os.path.basename(self.item.file_name.decode())
+
+    def save_to(self, destpath: str):
+        fsrc = self.item.file_object
+        fsrc.seek(0)
+        with open(destpath, 'wb') as fd:
+            shutil.copyfileobj(fsrc, fd)
+
+
+def get_request_params():
+    oforms = {}
+    ofiles = {}
+    if "REQUEST_URI" in os.environ:
+        qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
+        for name, values in qforms.items():
+            oforms[name] = values[0]
+    if "CONTENT_TYPE" in os.environ:
+        ctype = os.environ["CONTENT_TYPE"]
+        if ctype == "application/x-www-form-urlencoded":
+            s = sys.stdin.read()
+            qforms = parse.parse_qs(s)
+            for name, values in qforms.items():
+                oforms[name] = values[0]
+        elif ctype.startswith("multipart/"):
+            def on_field(field):
+                oforms[field.field_name.decode()] = field.value.decode()
+            def on_file(file):
+                ofiles[file.field_name.decode()] = FileItem(file)
+            multipart.parse_form(headers={"Content-Type": ctype},
+                                 input_stream=sys.stdin.buffer,
+                                 on_field=on_field, on_file=on_file)
+    return oforms, ofiles
+

Modified: httpd/httpd/trunk/test/modules/http2/htdocs/cgi/upload.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/htdocs/cgi/upload.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/htdocs/cgi/upload.py (original)
+++ httpd/httpd/trunk/test/modules/http2/htdocs/cgi/upload.py Thu Apr 27 11:35:51 2023
@@ -1,38 +1,7 @@
 #!/usr/bin/env python3
 import os
 import sys
-from urllib import parse
-import multipart # https://github.com/andrew-d/python-multipart (`apt install python3-multipart`)
-
-
-try:  # Windows needs stdio set for binary mode.
-    import msvcrt
-
-    msvcrt.setmode(0, os.O_BINARY)  # stdin  = 0
-    msvcrt.setmode(1, os.O_BINARY)  # stdout = 1
-except ImportError:
-    pass
-
-def get_request_params():
-    oforms = {}
-    ofiles = {}
-    if "REQUEST_URI" in os.environ:
-        qforms = parse.parse_qs(parse.urlsplit(os.environ["REQUEST_URI"]).query)
-        for name, values in qforms.items():
-            oforms[name] = values[0]
-    if "HTTP_CONTENT_TYPE" in os.environ:
-        ctype = os.environ["HTTP_CONTENT_TYPE"]
-        if ctype == "application/x-www-form-urlencoded":
-            qforms = parse.parse_qs(parse.urlsplit(sys.stdin.read()).query)
-            for name, values in qforms.items():
-                oforms[name] = values[0]
-        elif ctype.startswith("multipart/"):
-            def on_field(field):
-                oforms[field.field_name] = field.value
-            def on_file(file):
-                ofiles[field.field_name] = field.value
-            multipart.parse_form(headers={"Content-Type": ctype}, input_stream=sys.stdin.buffer, on_field=on_field, on_file=on_file)
-    return oforms, ofiles
+from requestparser import get_request_params
 
 
 forms, files = get_request_params()
@@ -43,9 +12,9 @@ status = '200 Ok'
 if 'file' in files:
     fitem = files['file']
     # strip leading path from file name to avoid directory traversal attacks
-    fname = fitem.filename
+    fname = os.path.basename(fitem.file_name)
     fpath = f'{os.environ["DOCUMENT_ROOT"]}/files/{fname}'
-    fitem.save_as(fpath)
+    fitem.save_to(fpath)
     message = "The file %s was uploaded successfully" % (fname)
     print("Status: 201 Created")
     print("Content-Type: text/html")

Modified: httpd/httpd/trunk/test/modules/http2/test_003_get.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/test_003_get.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/test_003_get.py (original)
+++ httpd/httpd/trunk/test/modules/http2/test_003_get.py Thu Apr 27 11:35:51 2023
@@ -194,10 +194,14 @@ content-type: text/html
     @pytest.mark.parametrize("path", [
         "/004.html", "/proxy/004.html", "/h2proxy/004.html"
     ])
-    def test_h2_003_50(self, env, path):
+    def test_h2_003_50(self, env, path, repeat):
         # check that the resource supports ranges and we see its raw content-length
         url = env.mkurl("https", "test1", path)
-        r = env.curl_get(url, 5)
+        # TODO: sometimes we see a 503 here from h2proxy
+        for i in range(10):
+            r = env.curl_get(url, 5)
+            if r.response["status"] != 503:
+                break
         assert r.response["status"] == 200
         assert "HTTP/2" == r.response["protocol"]
         h = r.response["header"]

Modified: httpd/httpd/trunk/test/modules/http2/test_004_post.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/test_004_post.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/test_004_post.py (original)
+++ httpd/httpd/trunk/test/modules/http2/test_004_post.py Thu Apr 27 11:35:51 2023
@@ -124,6 +124,7 @@ class TestPost:
         r = env.nghttp().upload_file(url, fpath, options=options)
         assert r.exit_code == 0
         assert r.response["status"] >= 200 and r.response["status"] < 300
+        assert 'location' in r.response["header"], f'{r}'
         assert r.response["header"]["location"]
 
         r2 = env.nghttp().get(r.response["header"]["location"])

Modified: httpd/httpd/trunk/test/modules/http2/test_200_header_invalid.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/modules/http2/test_200_header_invalid.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/modules/http2/test_200_header_invalid.py (original)
+++ httpd/httpd/trunk/test/modules/http2/test_200_header_invalid.py Thu Apr 27 11:35:51 2023
@@ -17,13 +17,14 @@ class TestInvalidHeaders:
     def test_h2_200_01(self, env):
         url = env.mkurl("https", "cgi", "/hecho.py")
         for x in range(1, 32):
-            r = env.curl_post_data(url, "name=x%%%02xx&value=yz" % x)
+            data = f'name=x%{x:02x}x&value=yz'
+            r = env.curl_post_data(url, data)
             if x in [13]:
-                assert 0 == r.exit_code, "unexpected exit code for char 0x%02x" % x
-                assert 200 == r.response["status"], "unexpected status for char 0x%02x" % x
+                assert 0 == r.exit_code, f'unexpected exit code for char 0x{x:02}'
+                assert 200 == r.response["status"], f'unexpected status for char 0x{x:02}'
             else:
-                assert 0 == r.exit_code, "unexpected exit code for char 0x%02x" % x
-                assert 500 == r.response["status"], "unexpected status for char 0x%02x" % x
+                assert 0 == r.exit_code, f'"unexpected exit code for char 0x{x:02}'
+                assert 500 == r.response["status"], f'posting "{data}" unexpected status, {r}'
 
     # let the hecho.py CGI echo chars < 0x20 in field value
     # for almost all such characters, the stream returns a 500

Modified: httpd/httpd/trunk/test/pyhttpd/nghttp.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/pyhttpd/nghttp.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/pyhttpd/nghttp.py (original)
+++ httpd/httpd/trunk/test/pyhttpd/nghttp.py Thu Apr 27 11:35:51 2023
@@ -247,11 +247,11 @@ class Nghttp:
     def post_name(self, url, name, timeout=5, options=None):
         reqbody = ("%s/nghttp.req.body" % self.TMP_DIR)
         with open(reqbody, 'w') as f:
-            f.write("--DSAJKcd9876\n")
-            f.write("Content-Disposition: form-data; name=\"value\"; filename=\"xxxxx\"\n")
-            f.write("Content-Type: text/plain\n")
-            f.write("\n%s\n" % name)
-            f.write("--DSAJKcd9876\n")
+            f.write("--DSAJKcd9876\r\n")
+            f.write("Content-Disposition: form-data; name=\"value\"; filename=\"xxxxx\"\r\n")
+            f.write("Content-Type: text/plain\r\n")
+            f.write(f"\r\n{name}")
+            f.write("\r\n--DSAJKcd9876\r\n")
         if not options:
             options = []
         options.extend([ 
@@ -270,20 +270,23 @@ class Nghttp:
         reqbody = ("%s/nghttp.req.body" % self.TMP_DIR)
         with open(fpath, 'rb') as fin:
             with open(reqbody, 'wb') as f:
-                f.write(("""--DSAJKcd9876
-Content-Disposition: form-data; name="xxx"; filename="xxxxx"
-Content-Type: text/plain
-
-testing mod_h2
---DSAJKcd9876
-Content-Disposition: form-data; name="file"; filename="%s"
-Content-Type: application/octet-stream
-Content-Transfer-Encoding: binary
-
-""" % fname).encode('utf-8'))
+                preamble = [
+                    '--DSAJKcd9876',
+                    'Content-Disposition: form-data; name="xxx"; filename="xxxxx"',
+                    'Content-Type: text/plain',
+                    '',
+                    'testing mod_h2',
+                    '\r\n--DSAJKcd9876',
+                    f'Content-Disposition: form-data; name="file"; filename="{fname}"',
+                    'Content-Type: application/octet-stream',
+                    'Content-Transfer-Encoding: binary',
+                    '', ''
+                ]
+                f.write('\r\n'.join(preamble).encode('utf-8'))
                 f.write(fin.read())
-                f.write("""
---DSAJKcd9876""".encode('utf-8'))
+                f.write('\r\n'.join([
+                    '\r\n--DSAJKcd9876', ''
+                ]).encode('utf-8'))
         if not options:
             options = []
         options.extend([ 

Modified: httpd/httpd/trunk/test/pyhttpd/result.py
URL: http://svn.apache.org/viewvc/httpd/httpd/trunk/test/pyhttpd/result.py?rev=1909452&r1=1909451&r2=1909452&view=diff
==============================================================================
--- httpd/httpd/trunk/test/pyhttpd/result.py (original)
+++ httpd/httpd/trunk/test/pyhttpd/result.py Thu Apr 27 11:35:51 2023
@@ -28,7 +28,14 @@ class ExecResult:
             self._json_out = None
 
     def __repr__(self):
-        return f"ExecResult[code={self.exit_code}, args={self._args}, stdout={self._stdout}, stderr={self._stderr}]"
+        out = [
+            f"ExecResult[code={self.exit_code}, args={self._args}\n",
+            "----stdout---------------------------------------\n",
+            self._stdout.decode(),
+            "----stderr---------------------------------------\n",
+            self._stderr.decode()
+        ]
+        return ''.join(out)
 
     @property
     def exit_code(self) -> int: