Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion doc/commands/abap.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@ Find more about the options you have when writing your ABAP snippet at:
[help.sap.com/adt-class-execution](https://help.sap.com/docs/btp/sap-business-technology-platform/adt-class-execution)

```bash
sapcli abap run [--prefix PREFIX] [--package PACKAGE] SOURCE
sapcli abap run [--prefix PREFIX] [--package PACKAGE] [-D NAME=VALUE] SOURCE
```

* _SOURCE_ path to a file containing ABAP statements, or `-` to read from _stdin_
* _--prefix PREFIX_ class name prefix (default: `zcl_sapcli_run`)
* _--package PACKAGE_ package for the temporary class (default: `$tmp`)
* _-D NAME=VALUE, --define NAME=VALUE_ replace `{{NAME}}` tokens in the source
with `VALUE` (repeatable; case sensitive; the last definition of the same
name wins)

The temporary class name follows the pattern `<prefix>_<username>_<random>` and is
exactly 30 characters long.
Expand All @@ -63,6 +66,30 @@ be disabled globally via the environment variable
purpose - `abap run` is internal orchestration; if the check itself misfires
the global env-var is the right knob.

### Preprocessor

The source is treated as a template: every `{{NAME}}` token is replaced with
the value given via `--define NAME=VALUE` before the code is sent to the
system. Whitespace inside the braces is allowed (`{{ NAME }}`), the token must
not span lines, and names follow the C identifier grammar
(`[A-Za-z_][A-Za-z0-9_]*`, ASCII only). The value may contain `=` - only the
first `=` separates the name from the value. Values are inserted literally;
they are not re-scanned for tokens. Substitution happens everywhere in the
source, including character literals and comments.

The preprocessor fails with an error instead of sending questionable code to
the system when the source contains:

* a token without a matching `--define` (a forgotten substitution),
* anything but a plain name between `{{` and `}}` - the content is reserved
for future template features (e.g. Jinja2 style filters),
* the Jinja2 delimiters `{%` or `{#` anywhere in the code - reserved for
future template statements and comments.

Because the reservation errors are based on the raw text, ABAP code with a
literal `{{`, `{%` or `{#` inside a character literal or a comment is
rejected too; there is no escape syntax yet.

### Run ABAP from a file

```bash
Expand All @@ -80,3 +107,9 @@ echo -n "out->write( 'Hello World!' )." | sapcli abap run -
```bash
sapcli abap run --prefix zcl_myrun --package '$mypackage' my_script.abap
```

### Substitute values in the source

```bash
echo -n "out->write( '{{GREETING}}, {{WHO}}!' )." | sapcli abap run --define GREETING=Hello --define WHO=World -
```
24 changes: 24 additions & 0 deletions sap/cli/abap.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ def __init__(self):
super().__init__('abap')


def _parse_definitions(define_args):
"""Turns a list of NAME=VALUE strings into a {NAME: VALUE} dict.

The value may contain '=' (only the first one separates name and value).
The name must follow the preprocessor token name grammar, otherwise the
definition could never match a token.
"""

definitions = {}

for item in define_args:
name, separator, value = item.partition('=')
if not separator or not sap.platform.abap.run.TOKEN_NAME_RE.fullmatch(name):
raise sap.errors.SAPCliError(f'Invalid --define value (expected NAME=VALUE): {item}')

definitions[name] = value

return definitions


@CommandGroup.argument('-D', '--define', type=str, action='append', metavar='NAME=VALUE',
help='Replace {{NAME}} tokens in the source with VALUE (repeatable)')
@CommandGroup.argument('--package', type=str, default=sap.platform.abap.run.DEFAULT_PACKAGE)
@CommandGroup.argument('--prefix', type=str, default=sap.platform.abap.run.DEFAULT_PREFIX)
@CommandGroup.argument('source', type=str)
Expand All @@ -32,6 +54,8 @@ def run(connection, args):
with open(args.source, 'r', encoding='utf-8') as source_file:
user_code = source_file.read()

user_code = sap.platform.abap.run.preprocess(user_code, _parse_definitions(args.define or []))

result = sap.platform.abap.run.execute_abap(
connection,
user_code,
Expand Down
55 changes: 55 additions & 0 deletions sap/platform/abap/run.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""ABAP Adhoc Code Execution"""

import re
import secrets
import string
import warnings
Expand All @@ -16,6 +17,22 @@

_CLASS_DESCRIPTION = 'Temporary class created by sapcli for adhoc ABAP execution'

# Preprocessor token: anything between {{ and }} on a single line. The match is
# deliberately broader than the supported content so that future syntax (defaults,
# filters, functions) fails loudly today instead of being passed to the ABAP system.
_TOKEN_RE = re.compile(r'{{(.*?)}}')

# Grammar of token names - shared with the --define argument parsing so that every
# definable name is also matchable. Future token syntax will use the characters
# excluded here (e.g. '|', '()') as operators.
TOKEN_NAME_RE = re.compile(r'[A-Za-z_]\w*', re.ASCII)

# Jinja2 statement and comment delimiters - reserved for future template features.
# Their mere presence is an error (paired or not, Jinja2 comments may span lines),
# so a future upgrade to full Jinja2 templating cannot silently change the meaning
# of sources which work today.
_RESERVED_DELIMITERS = ('{%', '{#')

_CLASS_TEMPLATE = '''"! This is a temporary class created by sapcli for execution
"! of an adhoc ABAP statements.
CLASS {class_name} DEFINITION
Expand Down Expand Up @@ -54,6 +71,44 @@ def generate_class_name(prefix, username):
return f'{prefix_lower}_{username_lower}_{random_chars}'


def preprocess(user_code, definitions):
"""Replaces every {{NAME}} token in user_code with definitions[NAME].

Raises SAPCliError if the code contains a token without a matching definition,
so a forgotten substitution fails loudly instead of reaching the ABAP system,
and also if a token holds anything but a plain name - the content between the
braces is reserved for future syntax. The Jinja2 delimiters '{%' and '{#' are
reserved too and must not appear in the code at all. Only the original code is
checked - substituted values are inserted literally and never re-scanned.
"""

reserved = [delimiter for delimiter in _RESERVED_DELIMITERS if delimiter in user_code]
if reserved:
names = ', '.join(reserved)
raise SAPCliError(f'Reserved preprocessor delimiter(s): {names}')

undefined = set()

def _replace(match):
name = match.group(1).strip()
if not TOKEN_NAME_RE.fullmatch(name):
raise SAPCliError(f'Unsupported preprocessor expression: {match.group(0)}')

if name not in definitions:
undefined.add(name)
return match.group(0)

return definitions[name]

result = _TOKEN_RE.sub(_replace, user_code)

if undefined:
names = ', '.join(sorted(undefined))
raise SAPCliError(f'Undefined preprocessor token(s): {names}')

return result


def build_class_code(class_name, user_code):
"""Assembles the ABAP class source code wrapping user_code in if_oo_adt_classrun~main"""

Expand Down
169 changes: 169 additions & 0 deletions test/unit/test_sap_cli_abap.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,127 @@ def test_run_custom_package(self):
package='$mypackage'
)

def test_run_with_define_substitutes_token(self):
file_content = 'WRITE {{VALUE}}.'

with patch('builtins.open', mock_open(read_data=file_content)):
mock_exec, _ = self._run_with_mock(
['run', '--define', 'VALUE=42', 'script.abap']
)

mock_exec.assert_called_once_with(
'mock_connection',
'WRITE 42.',
prefix=sap.platform.abap.run.DEFAULT_PREFIX,
package=sap.platform.abap.run.DEFAULT_PACKAGE
)

def test_run_with_define_short_flag(self):
file_content = 'WRITE {{VALUE}}.'

with patch('builtins.open', mock_open(read_data=file_content)):
mock_exec, _ = self._run_with_mock(
['run', '-D', 'VALUE=42', 'script.abap']
)

mock_exec.assert_called_once_with(
'mock_connection',
'WRITE 42.',
prefix=sap.platform.abap.run.DEFAULT_PREFIX,
package=sap.platform.abap.run.DEFAULT_PACKAGE
)

def test_run_with_multiple_defines(self):
file_content = '{{A}} {{B}}'

with patch('builtins.open', mock_open(read_data=file_content)):
mock_exec, _ = self._run_with_mock(
['run', '--define', 'A=foo', '--define', 'B=bar', 'script.abap']
)

mock_exec.assert_called_once_with(
'mock_connection',
'foo bar',
prefix=sap.platform.abap.run.DEFAULT_PREFIX,
package=sap.platform.abap.run.DEFAULT_PACKAGE
)

def test_run_without_define_token_in_source_raises(self):
file_content = 'WRITE {{VALUE}}.'

with patch('builtins.open', mock_open(read_data=file_content)):
args = parse_args(['run', 'script.abap'])
_, factory = make_console_factory()
args.console_factory = factory

with patch('sap.platform.abap.run.execute_abap') as mock_exec:
with self.assertRaises(SAPCliError) as caught:
args.execute('mock_connection', args)

mock_exec.assert_not_called()

self.assertIn('VALUE', str(caught.exception))

def test_run_define_value_may_contain_equals(self):
file_content = 'cond = {{EXPR}}.'

with patch('builtins.open', mock_open(read_data=file_content)):
mock_exec, _ = self._run_with_mock(
['run', '--define', 'EXPR=a = b', 'script.abap']
)

mock_exec.assert_called_once_with(
'mock_connection',
'cond = a = b.',
prefix=sap.platform.abap.run.DEFAULT_PREFIX,
package=sap.platform.abap.run.DEFAULT_PACKAGE
)

def test_run_undefined_token_raises_and_skips_execution(self):
file_content = 'WRITE {{MISSING}}.'

with patch('builtins.open', mock_open(read_data=file_content)):
args = parse_args(['run', '--define', 'OTHER=1', 'script.abap'])
_, factory = make_console_factory()
args.console_factory = factory

with patch('sap.platform.abap.run.execute_abap') as mock_exec:
with self.assertRaises(SAPCliError) as caught:
args.execute('mock_connection', args)

mock_exec.assert_not_called()

self.assertIn('MISSING', str(caught.exception))

def test_run_reserved_delimiter_raises_and_skips_execution(self):
file_content = '{% if x %}WRITE.{% endif %}'

with patch('builtins.open', mock_open(read_data=file_content)):
args = parse_args(['run', 'script.abap'])
_, factory = make_console_factory()
args.console_factory = factory

with patch('sap.platform.abap.run.execute_abap') as mock_exec:
with self.assertRaises(SAPCliError) as caught:
args.execute('mock_connection', args)

mock_exec.assert_not_called()

self.assertIn('{%', str(caught.exception))

def test_run_define_invalid_format_raises(self):
file_content = 'WRITE.'

with patch('builtins.open', mock_open(read_data=file_content)):
args = parse_args(['run', '--define', 'NOEQUALS', 'script.abap'])
_, factory = make_console_factory()
args.console_factory = factory

with self.assertRaises(SAPCliError) as caught:
args.execute('mock_connection', args)

self.assertIn('NOEQUALS', str(caught.exception))

def test_run_prints_output(self):
file_content = 'WRITE.'
expected_output = 'execution result'
Expand All @@ -145,6 +266,54 @@ def test_run_prints_output(self):
self.assertEqual(console.capout, expected_output + '\n')


class TestParseDefinitions(unittest.TestCase):

def test_single_definition(self):
self.assertEqual(sap.cli.abap._parse_definitions(['A=1']), {'A': '1'})

def test_multiple_definitions(self):
self.assertEqual(
sap.cli.abap._parse_definitions(['A=1', 'B=2']),
{'A': '1', 'B': '2'}
)

def test_value_may_contain_equals(self):
self.assertEqual(sap.cli.abap._parse_definitions(['A=1=2']), {'A': '1=2'})

def test_empty_value_allowed(self):
self.assertEqual(sap.cli.abap._parse_definitions(['A=']), {'A': ''})

def test_later_definition_overrides_earlier(self):
self.assertEqual(sap.cli.abap._parse_definitions(['A=1', 'A=2']), {'A': '2'})

def test_missing_equals_raises(self):
with self.assertRaises(SAPCliError):
sap.cli.abap._parse_definitions(['NOEQ'])

def test_empty_name_raises(self):
with self.assertRaises(SAPCliError):
sap.cli.abap._parse_definitions(['=value'])

def test_name_with_underscore_allowed(self):
self.assertEqual(sap.cli.abap._parse_definitions(['MY_NAME=1']), {'MY_NAME': '1'})

def test_name_with_space_raises(self):
with self.assertRaises(SAPCliError):
sap.cli.abap._parse_definitions(['A B=1'])

def test_name_with_colon_dash_raises(self):
with self.assertRaises(SAPCliError):
sap.cli.abap._parse_definitions(['A:-B=1'])

def test_name_with_pipe_raises(self):
with self.assertRaises(SAPCliError):
sap.cli.abap._parse_definitions(['A|B=1'])

def test_name_starting_with_digit_raises(self):
with self.assertRaises(SAPCliError):
sap.cli.abap._parse_definitions(['1A=2'])


class TestAbapSystemInfo(unittest.TestCase):

def test_systeminfo_prints_all_entries(self):
Expand Down
Loading
Loading