From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from [87.239.111.99] (localhost [127.0.0.1]) by dev.tarantool.org (Postfix) with ESMTP id C3F346C16C2; Wed, 18 Oct 2023 16:17:12 +0300 (MSK) DKIM-Filter: OpenDKIM Filter v2.11.0 dev.tarantool.org C3F346C16C2 DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=tarantool.org; s=dev; t=1697635032; bh=qxIc3WEleB7tCuRjkcMzxHyXrPKCwjiT2c8W80HLT60=; h=Date:To:References:In-Reply-To:Subject:List-Id:List-Unsubscribe: List-Archive:List-Post:List-Help:List-Subscribe:From:Reply-To: From; b=QPJMNBDUlzMBwVDa8UcoT8JA0lGLASH8tQaUwSWk66DHn81txnYgpLJLLkvDoTzno lh0R5ulZq5M44We7xMM/Q+oofV0jR7IH4PvimsJxCGb7LTTjpyeCW1r3O9d2MH06+K 0TMjuCQopeMf26046PKIdT5Ova1ZCg0QqvdgPxLQ= Received: from smtp33.i.mail.ru (smtp33.i.mail.ru [95.163.41.74]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by dev.tarantool.org (Postfix) with ESMTPS id 0C4D86BA5BA for ; Wed, 18 Oct 2023 16:17:11 +0300 (MSK) DKIM-Filter: OpenDKIM Filter v2.11.0 dev.tarantool.org 0C4D86BA5BA Received: by smtp33.i.mail.ru with esmtpa (envelope-from ) id 1qt6Q9-00Cg8m-3C; Wed, 18 Oct 2023 16:17:10 +0300 Message-ID: <477effbc-2e4c-4875-8d87-d3a1ba02a4c5@tarantool.org> Date: Wed, 18 Oct 2023 16:17:09 +0300 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Content-Language: en-US To: Maksim Kokryashkin , tarantool-patches@dev.tarantool.org, skaplun@tarantool.org, m.kokryashkin@tarantool.org, imun@tarantool.org References: <20231012102536.41994-1-max.kokryashkin@gmail.com> <20231012102536.41994-3-max.kokryashkin@gmail.com> In-Reply-To: <20231012102536.41994-3-max.kokryashkin@gmail.com> Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: 8bit X-Mailru-Src: smtp X-4EC0790: 10 X-7564579A: 646B95376F6C166E X-77F55803: 4F1203BC0FB41BD978AFEFB3096932CF12F77ADD25EC5C9658AC0226C7A531B7182A05F53808504068CCD77F9FEE3FE0062BF2CB8ADB2B033EB7908A12064D24276DE5C7CF38568F X-7FA49CB5: FF5795518A3D127A4AD6D5ED66289B5278DA827A17800CE72E2D36A15E1833D8EA1F7E6F0F101C67BD4B6F7A4D31EC0BCC500DACC3FED6E28638F802B75D45FF8AA50765F7900637B103A303C52566448638F802B75D45FF36EB9D2243A4F8B5A6FCA7DBDB1FC311F39EFFDF887939037866D6147AF826D8B5FA39E68BCBDA343274BE5DDCE1D8FA117882F4460429724CE54428C33FAD305F5C1EE8F4F765FC47272755C61AA17BA471835C12D1D9774AD6D5ED66289B52BA9C0B312567BB23117882F44604297287769387670735201E561CDFBCA1751FE5D25F19253116ADD2E47CDBA5A96583BA9C0B312567BB2376E601842F6C81A19E625A9149C048EE9647ADFADE5905B1F206494F22AA87D6D8FC6C240DEA76429C9F4D5AE37F343AA9539A8B242431040A6AB1C7CE11FEE34CB6874B0BCFF0B8C0837EA9F3D19764C4224003CC836476E2F48590F00D11D6E2021AF6380DFAD1A18204E546F3947C0CABCCA60F52D7EB2E808ACE2090B5E1725E5C173C3A84C3E478A468B35FE767089D37D7C0E48F6C8AA50765F79006373B9693DF9B93E282731C566533BA786AA5CC5B56E945C8DA X-C1DE0DAB: 0D63561A33F958A5314E9DF79DFAA5F122A3407B746B013267725044FBC5C6C9F87CCE6106E1FC07E67D4AC08A07B9B01F9513A7CA91E555CB5012B2E24CD356 X-C8649E89: 1C3962B70DF3F0ADE00A9FD3E00BEEDF3FED46C3ACD6F73ED3581295AF09D3DF87807E0823442EA2ED31085941D9CD0AF7F820E7B07EA4CFD8640F2125E763CA7506E3F34C009804F0E80B0A096CA690BCA7FC98746D7A51E781F4E203C570785BB39EF8E370AEADE9A5C9F165087AA4087614D00985BAF2461A413F07889F2102C26D483E81D6BE0DBAE6F56676BC7117BB6831D7356A2DEC5B5AD62611EEC62B5AFB4261A09AF0 X-D57D3AED: 3ZO7eAau8CL7WIMRKs4sN3D3tLDjz0dLbV79QFUyzQ2Ujvy7cMT6pYYqY16iZVKkSc3dCLJ7zSJH7+u4VD18S7Vl4ZUrpaVfd2+vE6kuoey4m4VkSEu530nj6fImhcD4MUrOEAnl0W826KZ9Q+tr5ycPtXkTV4k65bRjmOUUP8cvGozZ33TWg5HZplvhhXbhDGzqmQDTd6OAevLeAnq3Ra9uf7zvY2zzsIhlcp/Y7m53TZgf2aB4JOg4gkr2bioj+FeGWQZfdZKfz/yGJgv5Eg== X-Mailru-Sender: 11C2EC085EDE56FAC07928AF2646A76929A807D6DF1942DA062BF2CB8ADB2B03C59A7EFB72EB47B2EBA65886582A37BD66FEC6BF5C9C28D98A98C1125256619760D574B6FC815AB872D6B4FCE48DF648AE208404248635DF X-Mras: Ok Subject: Re: [Tarantool-patches] [PATCH luajit v2 2/2] test: add test for debugging extension X-BeenThere: tarantool-patches@dev.tarantool.org X-Mailman-Version: 2.1.34 Precedence: list List-Id: Tarantool development patches List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , From: Sergey Bronnikov via Tarantool-patches Reply-To: Sergey Bronnikov Errors-To: tarantool-patches-bounces@dev.tarantool.org Sender: "Tarantool-patches" Hello, Max thanks for the patches.  See my comments. Please put this patch first, before refactoring. On 10/12/23 13:25, Maksim Kokryashkin wrote: > This patch introduces Test::Base-like tests for the > LuaJIT debugging extension. The newly introduced test > suite is TAP-compatible and is tested with prove. > Test specification is generalized to a great extent, > however, it is still important to keep in mind the > platform-specific aspects of assertions. > --- > test/CMakeLists.txt | 3 + > test/tarantool-debugger-tests/CMakeLists.txt | 82 ++++++++++ > test/tarantool-debugger-tests/config.py | 148 ++++++++++++++++++ > .../luajit_dbg.test.md | 136 ++++++++++++++++ > test/tarantool-debugger-tests/run.py | 8 + > test/tarantool-debugger-tests/test_base.py | 73 +++++++++ > 6 files changed, 450 insertions(+) > create mode 100644 test/tarantool-debugger-tests/CMakeLists.txt > create mode 100644 test/tarantool-debugger-tests/config.py > create mode 100644 test/tarantool-debugger-tests/luajit_dbg.test.md Why do you use .md extension if it is not a document but a file with testcases? Why markdown format and not a YAML? > create mode 100755 test/tarantool-debugger-tests/run.py > create mode 100644 test/tarantool-debugger-tests/test_base.py > > diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt > index 58cba5ba..87ee40b3 100644 > --- a/test/CMakeLists.txt > +++ b/test/CMakeLists.txt > @@ -78,6 +78,7 @@ add_subdirectory(PUC-Rio-Lua-5.1-tests) > add_subdirectory(lua-Harness-tests) > add_subdirectory(tarantool-c-tests) > add_subdirectory(tarantool-tests) > +add_subdirectory(tarantool-debugger-tests) Nit: it is actually not tests for debugger, but for debugger extensions > > add_custom_target(${PROJECT_NAME}-test DEPENDS > LuaJIT-tests > @@ -85,6 +86,8 @@ add_custom_target(${PROJECT_NAME}-test DEPENDS > lua-Harness-tests > tarantool-c-tests > tarantool-tests > + tarantool-gdb-tests > + tarantool-lldb-tests > ) > > if(LUAJIT_USE_TEST) > diff --git a/test/tarantool-debugger-tests/CMakeLists.txt b/test/tarantool-debugger-tests/CMakeLists.txt > new file mode 100644 > index 00000000..ffe8ff39 > --- /dev/null > +++ b/test/tarantool-debugger-tests/CMakeLists.txt > @@ -0,0 +1,82 @@ > +add_custom_target(tarantool-gdb-tests Why "tarantool-" and not "LuaJIT-"? Nothing specific for Tarantool here. Same below. > + DEPENDS ${LUAJIT_TEST_BINARY} > +) > + > +add_custom_target(tarantool-lldb-tests > + DEPENDS ${LUAJIT_TEST_BINARY} > +) > + > +# Debug info is required for testing of extensions. > +if(NOT (CMAKE_BUILD_TYPE MATCHES Debug)) > + message(WARNING "not a DEBUG build, tarantool-*db-tests are dummy") Why do you use reduced target name in messages? here and below. > + return() > +endif() > + > +# MacOS asks for permission to debug a process even when the > +# machine is set into development mode. To solve the issue, > +# it is required to add relevant users to the `_developer` user > +# group in MacOS. Disabled for now. > +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin" AND DEFINED ENV{CI}) > + message(WARNING "non-interactive debugging on macOS, tarantool-*db-tests are dummy") probably you mean something like: non-interactive debugging on macOS is not available > + return() > +endif() > + > +find_program(PROVE prove) > +if(NOT PROVE) > + message(WARNING "`prove' is not found, so tarantool-*db-tests target are dummy") > + return() > +endif() What is a point to search PROVE four times? ../tarantool-tests/CMakeLists.txt:find_program(PROVE prove) ../tarantool-debugger-tests/CMakeLists.txt:find_program(PROVE prove) ../tarantool-c-tests/CMakeLists.txt:find_program(PROVE prove) ../lua-Harness-tests/CMakeLists.txt:find_program(PROVE prove) I suspect one time is more than enough. > + > +find_package(PythonInterp) Deprecated since version 3.12: Use FindPython3, FindPython2 or FindPython instead. https://cmake.org/cmake/help/latest/module/FindPythonInterp.html > +if(NOT PYTHONINTERP_FOUND) > + message(WARNING "`python' is not found, so tarantool-*db-tests target are dummy") > + return() > +endif() > + > +set(DEBUGGER_TEST_FLAGS --failures) > +if(CMAKE_VERBOSE_MAKEFILE) > + list(APPEND DEBUGGER_TEST_FLAGS --verbose) > +endif() > + > +set(DEBUGGER_TEST_ENV > + "LUAJIT_TEST_BINARY=${LUAJIT_TEST_BINARY}" > + # Suppresses __pycache__ generation. > + "PYTHONDONTWRITEBYTECODE=1" > + "DEBUGGER_EXTENSION_PATH=${PROJECT_SOURCE_DIR}/src/luajit_dbg.py" > +) > + > +find_program(GDB gdb) > +if(GDB) > + set(GDB_TEST_ENV ${DEBUGGER_TEST_ENV} > + "DEBUGGER_COMMAND=${GDB}" > + ) > + add_custom_command(TARGET tarantool-gdb-tests > + COMMENT "Running luajit_dbg.py tests with gdb" > + COMMAND > + ${GDB_TEST_ENV} > + ${PROVE} ${CMAKE_CURRENT_SOURCE_DIR}/luajit_dbg.test.md > + --exec '${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run.py' > + ${DEBUGGER_TEST_FLAGS} > + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} > + ) > +else() > + message(WARNING "`gdb' is not found, so tarantool-gdb-tests target is dummy") > +endif() > + > +find_program(LLDB lldb) > +if(LLDB) > + set(LLDB_TEST_ENV ${DEBUGGER_TEST_ENV} > + "DEBUGGER_COMMAND=${LLDB}" > + ) > + add_custom_command(TARGET tarantool-lldb-tests > + COMMENT "Running luajit_dbg.py tests with lldb" > + COMMAND > + ${LLDB_TEST_ENV} > + ${PROVE} ${CMAKE_CURRENT_SOURCE_DIR}/luajit_dbg.test.md > + --exec '${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run.py' > + ${DEBUGGER_TEST_FLAGS} > + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} > + ) > +else() > + message(WARNING "`lldb' is not found, so tarantool-lldb-tests target is dummy") > +endif() > diff --git a/test/tarantool-debugger-tests/config.py b/test/tarantool-debugger-tests/config.py > new file mode 100644 > index 00000000..a75d1452 > --- /dev/null > +++ b/test/tarantool-debugger-tests/config.py > @@ -0,0 +1,148 @@ > +# This file provides filters and logic for running the debugger tests. It is > +# imported by the runner and the test_base.py, so its symbols become exposed > +# to the globals(). Thus, they can be called during the specification > +# execution. > +import re > +import subprocess > +import os > +import sys > +import tempfile > +from threading import Timer > + > +LEGACY = re.match(r'^2\.', sys.version) > + > +LUAJIT_BINARY = os.environ['LUAJIT_TEST_BINARY'] > +EXTENSION = os.environ['DEBUGGER_EXTENSION_PATH'] > +DEBUGGER_COMMAND = os.environ['DEBUGGER_COMMAND'] > +LLDB = 'lldb' in DEBUGGER_COMMAND > +TIMEOUT = 10 > + > +active_block = None > +output = '' > +dbg_cmds = '' > + > + > +def persist(data): > + tmp = tempfile.NamedTemporaryFile(mode='w') > + tmp.write(data) > + tmp.flush() > + return tmp > + > + > +def execute_process(cmd, timeout=TIMEOUT): > + if LEGACY: > + # XXX: The Python 2.7 version of `subprocess.Popen` doesn't have a > + # timeout option, so the required functionality was implemented via > + # `threading.Timer`. > + process = subprocess.Popen(cmd, stdout=subprocess.PIPE) > + timer = Timer(TIMEOUT, process.kill) > + timer.start() > + stdout, _ = process.communicate() > + timer.cancel() > + > + # XXX: If the timeout is exceeded and the process is killed by the > + # timer, then the return code is non-zero, and we are going to blow up. > + assert process.returncode == 0 > + return stdout.decode('ascii') > + else: > + process = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT) > + return process.stdout.decode('ascii') > + > + > +def load_extension_cmd(): > + load_cmd = 'command script import {ext}' if LLDB else 'source {ext}' > + return load_cmd.format(ext=EXTENSION) > + > + > +def filter_debugger_output(output): > + descriptor = '(lldb)' if LLDB else '(gdb)' > + return ''.join( > + filter( > + lambda line: not line.startswith(descriptor), > + output.splitlines(True), > + ) > + ) > + > + > +def lua(data): > + global output > + > + exec_file_flag = '-s' if LLDB else '-x' > + inferior_args_flag = '--' if LLDB else '--args' > + > + tmp_cmds = persist(dbg_cmds) > + lua_script = persist(data) > + > + process_cmd = [ > + DEBUGGER_COMMAND, > + exec_file_flag, > + tmp_cmds.name, > + inferior_args_flag, > + LUAJIT_BINARY, > + lua_script.name, > + ] > + > + output = execute_process(process_cmd) > + output = filter_debugger_output(output) > + > + tmp_cmds.close() > + lua_script.close() > + > + > +def run_until_breakpoint(location): > + return [ > + 'b {loc}'.format(loc=location), > + 'process launch' if LLDB else 'r', > + 'n', > + ] > + > + > +def lj_cf_print(data): > + return run_until_breakpoint('lj_cf_print'), data > + > + > +def lj_cf_dofile(data): > + return run_until_breakpoint('lj_cf_dofile'), data > + > + > +def lj_cf_unpack(data): > + return run_until_breakpoint('lj_cf_unpack'), data > + > + > +def debug(data): > + global dbg_cmds > + setup_cmds, extension_cmds = data > + setup_cmds.append(load_extension_cmd()) > + > + if extension_cmds: > + setup_cmds.append(extension_cmds) > + > + setup_cmds.append('q') > + dbg_cmds = '\n'.join(setup_cmds) > + > + > +def test_ok(result): > + status = 'ok' if result else 'not ok' > + print( > + '{status} - {test_name}'.format( > + status=status, > + test_name=active_block.name, > + ) > + ) > + > + > +def expected(data): > + test_ok(data in output) > + > + > +def matches(data): > + test_ok(re.search(data, output)) > + > + > +def runner(blocks): > + print('1..{n}'.format(n=len(blocks))) > + global active_block > + for block in blocks: > + active_block = block > + for section in block.sections: > + globals()[section.name](section.pipeline(section.data.strip())) > diff --git a/test/tarantool-debugger-tests/luajit_dbg.test.md b/test/tarantool-debugger-tests/luajit_dbg.test.md > new file mode 100644 > index 00000000..d2003328 > --- /dev/null > +++ b/test/tarantool-debugger-tests/luajit_dbg.test.md > @@ -0,0 +1,136 @@ > +## smoke > +### debug lj_cf_print > +### lua > +print(1) > +### expected > +lj-tv command intialized > +lj-state command intialized > +lj-arch command intialized > +lj-gc command intialized > +lj-str command intialized > +lj-tab command intialized > +lj-stack command intialized > +LuaJIT debug extension is successfully loaded > + > + > +## lj-arch > +### debug lj_cf_print > +lj-arch > +### lua > +print(1) > +### matches > +LJ_64: (True|False), LJ_GC64: (True|False), LJ_DUALNUM: (True|False) > + > + > +## lj-state > +### debug lj_cf_print > +lj-state > +### lua > +print(1) > +### matches > +VM state: [A-Z]+ > +GC state: [A-Z]+ > +JIT state: [A-Z]+ > + > + > +## lj-gc > +### debug lj_cf_print > +lj-gc > +### lua > +print(1) > +### matches > +GC stats: [A-Z]+ > +\ttotal: \d+ > +\tthreshold: \d+ > +\tdebt: \d+ > +\testimate: \d+ > +\tstepmul: \d+ > +\tpause: \d+ > +\tsweepstr: \d+/\d+ > +\troot: \d+ objects > +\tgray: \d+ objects > +\tgrayagain: \d+ objects > +\tweak: \d+ objects > +\tmmudata: \d+ objects > + > + > +## lj-stack > +### debug lj_cf_print > +lj-stack > +### lua > +print(1) > +### matches > +-+ Red zone:\s+\d+ slots -+ > +(0x[a-zA-Z0-9]+\s+\[(S|\s)(B|\s)(T|\s)(M|\s)\] VALUE: nil\n?)* > +-+ Stack:\s+\d+ slots -+ > +(0x[A-Za-z0-9]+(:0x[A-Za-z0-9]+)?\s+\[(S|\s)(B|\s)(T|\s)(M|\s)\].*\n?)+ > + > + > +## lj-tv > +### debug lj_cf_print > +lj-tv L->base > +lj-tv L->base + 1 > +lj-tv L->base + 2 > +lj-tv L->base + 3 > +lj-tv L->base + 4 > +lj-tv L->base + 5 > +lj-tv L->base + 6 > +lj-tv L->base + 7 > +lj-tv L->base + 8 > +lj-tv L->base + 9 > +lj-tv L->base + 10 > +lj-tv L->base + 11 > +### lua > +local ffi = require('ffi') > + > +print( > + nil, > + false, > + true, > + "hello", > + {1}, > + 1, > + 1.1, > + coroutine.create(function() end), > + ffi.new('int*'), > + function() end, > + print, > + require > +) trailing space > +### matches > +nil > +false > +true > +string \"hello\" @ 0x[a-zA-Z0-9]+ > +table @ 0x[a-zA-Z0-9]+ \(asize: \d+, hmask: 0x[a-zA-Z0-9]+\) > +(number|integer) .*1.* > +number 1.1\d+ > +thread @ 0x[a-zA-Z0-9]+ > +cdata @ 0x[a-zA-Z0-9]+ > +Lua function @ 0x[a-zA-Z0-9]+, [0-9]+ upvalues, .+:[0-9]+ > +fast function #[0-9]+ > +C function @ 0x[a-zA-Z0-9]+ > + > + > +## lj-str > +### debug lj_cf_dofile > +lj-str fname > +### lua > +pcall(dofile('name')) > +### matches > +String: .* \[\d+ bytes\] with hash 0x[a-zA-Z0-9]+ > + > + > +## lj-tab > +### debug lj_cf_unpack > +lj-tab t > +### lua > +unpack({1; a = 1}) > +### matches > +Array part: 3 slots > +0x[a-zA-Z0-9]+: \[0\]: nil > +0x[a-zA-Z0-9]+: \[1\]: .+ 1 > +0x[a-zA-Z0-9]+: \[2\]: nil > +Hash part: 2 nodes > +0x[a-zA-Z0-9]+: { string "a" @ 0x[a-zA-Z0-9]+ } => { .+ 1 }; next = 0x0 > +0x[a-zA-Z0-9]+: { nil } => { nil }; next = 0x0 > diff --git a/test/tarantool-debugger-tests/run.py b/test/tarantool-debugger-tests/run.py > new file mode 100755 > index 00000000..cc84940d > --- /dev/null > +++ b/test/tarantool-debugger-tests/run.py > @@ -0,0 +1,8 @@ > +# Runner script for compatibility with `prove`. Why do we need prove here for running tests? Python is not a Lua and has builtin unit-testing library "unittest" [1]. Prove здесь как пятая нога. With current approach it is difficult to follow testing progress: [100%] Linking C executable luajit [100%] Built target luajit_static Running luajit_dbg.py tests with gdb /home/sergeyb/sources/MRG/tarantool/third_party/luajit/test/tarantool-debugger-tests/luajit_dbg.test.md Prove shows either *all* testcases passed or failed  testcases. It is not convenient. 1. https://docs.python.org/3/library/unittest.html > +import sys > + > +from config import runner > +from test_base import Spec > + > +with open(sys.argv[1], 'r') as stream: > + runner(Spec(stream.read()).blocks) > diff --git a/test/tarantool-debugger-tests/test_base.py b/test/tarantool-debugger-tests/test_base.py > new file mode 100644 > index 00000000..9d7931bd > --- /dev/null > +++ b/test/tarantool-debugger-tests/test_base.py > @@ -0,0 +1,73 @@ > +# This file provides a pythonic implementation of similar to Perl's Test::Base > +# module functionality for declarative testing. > +# See https://metacpan.org/pod/Test::Base#Rolling-Your-Own-Filters > +from config import * # noqa: F401,F403 > + > + > +class Pipeline(object): > + def __init__(self, funcs): > + self.funcs = funcs > + > + def __call__(self, data): > + for func in self.funcs: > + data = func(data) > + return data > + > + > +class Section(object): > + def __init__(self, name, pipeline): > + self.name = name > + self.data = '' > + self.pipeline = pipeline > + > + > +class Block(object): > + def __init__(self, name): > + self.name = name > + self.description = '' > + self.sections = [] > + > + > +class Spec(object): > + def __init__( > + self, > + spec, > + block_descriptor='## ', > + section_descriptor='### ', > + ): > + self.blocks = [] > + self.block_descriptor = block_descriptor > + self.section_descriptor = section_descriptor > + self.parse_spec(spec) > + > + def _is_block_start(self, line): > + return line.startswith(self.block_descriptor) > + > + def _is_section_start(self, line): > + return line.startswith(self.section_descriptor) > + > + def _is_description(self, line): > + return not self.blocks[-1].sections > + > + def parse_spec( > + self, > + spec, > + ): Put arguments in a single line. > + spec = spec.strip().splitlines(True) > + > + for line in spec: > + if self._is_block_start(line): > + name = line.lstrip(self.block_descriptor).strip() > + self.blocks.append(Block(name)) > + > + elif self._is_section_start(line): > + meta = line.lstrip(self.section_descriptor).strip().split() > + name = meta[0] > + pipeline = Pipeline([globals()[fname] for fname in meta[1:]]) > + self.blocks[-1].sections.append(Section(name, pipeline)) > + > + elif self._is_description(line): > + self.blocks[-1].description += line > + > + else: > + self.blocks[-1].sections[-1].data += line