diff --git a/.github/workflows/test_corpus.yaml b/.github/workflows/test_corpus.yaml index c1c5b744..c0b4d78d 100644 --- a/.github/workflows/test_corpus.yaml +++ b/.github/workflows/test_corpus.yaml @@ -17,7 +17,7 @@ on: type: boolean description: 'Regenerate results' required: true - default: true + default: false workflow_call: inputs: ref: diff --git a/.github/workflows/xtest.yaml b/.github/workflows/xtest.yaml index 519e7d53..245114de 100644 --- a/.github/workflows/xtest.yaml +++ b/.github/workflows/xtest.yaml @@ -26,7 +26,7 @@ jobs: - name: Run tests run: | - + if [[ "${{ matrix.python }}" == "python3.4" ]]; then (cd /usr/lib64/python3.4/test && python3.4 make_ssl_certs.py) elif [[ "${{ matrix.python }}" == "python3.5" ]]; then diff --git a/corpus_test/generate_report.py b/corpus_test/generate_report.py index 8ce94dbf..fccf9beb 100644 --- a/corpus_test/generate_report.py +++ b/corpus_test/generate_report.py @@ -6,7 +6,7 @@ from result import Result, ResultReader -ENHANCED_REPORT = os.environ.get('ENHANCED_REPORT', False) +ENHANCED_REPORT = os.environ.get('ENHANCED_REPORT', True) @dataclass @@ -64,6 +64,9 @@ def mean_percent_of_original(self) -> float: def larger_than_original(self) -> Iterable[Result]: """Return those entries that have a larger minified size than the original size""" for result in self.entries.values(): + if result.outcome != 'Minified': + continue + if result.original_size < result.minified_size: yield result @@ -91,10 +94,18 @@ def compare_size_increase(self, base: 'ResultSet') -> Iterable[Result]: """ for result in self.entries.values(): + if result.outcome != 'Minified': + # This result was not minified, so we can't compare + continue + if result.corpus_entry not in base.entries: continue base_result = base.entries[result.corpus_entry] + if base_result.outcome != 'Minified': + # The base result was not minified, so we can't compare + continue + if result.minified_size > base_result.minified_size: yield result @@ -104,10 +115,17 @@ def compare_size_decrease(self, base: 'ResultSet') -> Iterable[Result]: """ for result in self.entries.values(): + if result.outcome != 'Minified': + continue + if result.corpus_entry not in base.entries: continue base_result = base.entries[result.corpus_entry] + if base_result.outcome != 'Minified': + # The base result was not minified, so we can't compare + continue + if result.minified_size < base_result.minified_size: yield result @@ -164,6 +182,103 @@ def format_difference(compare: Iterable[Result], base: Iterable[Result]) -> str: else: return s +def report_larger_than_original(results_dir: str, python_versions: str, minifier_sha: str) -> str: + yield ''' +## Larger than original + +| Corpus Entry | Original Size | Minified Size | +|--------------|--------------:|--------------:|''' + + for python_version in python_versions: + try: + summary = result_summary(results_dir, python_version, minifier_sha) + except FileNotFoundError: + continue + + larger_than_original = sorted(summary.larger_than_original(), key=lambda result: result.original_size) + + for entry in larger_than_original: + yield f'| {entry.corpus_entry} | {entry.original_size} | {entry.minified_size} ({entry.minified_size - entry.original_size:+}) |' + +def report_unstable(results_dir: str, python_versions: str, minifier_sha: str) -> str: + yield ''' +## Unstable + +| Corpus Entry | Python Version | Original Size | +|--------------|----------------|--------------:|''' + + for python_version in python_versions: + try: + summary = result_summary(results_dir, python_version, minifier_sha) + except FileNotFoundError: + continue + + unstable = sorted(summary.unstable_minification(), key=lambda result: result.original_size) + + for entry in unstable: + yield f'| {entry.corpus_entry} | {python_version} | {entry.original_size} |' + +def report_exceptions(results_dir: str, python_versions: str, minifier_sha: str) -> str: + yield ''' +## Exceptions + +| Corpus Entry | Python Version | Exception | +|--------------|----------------|-----------|''' + + exceptions_found = False + + for python_version in python_versions: + try: + summary = result_summary(results_dir, python_version, minifier_sha) + except FileNotFoundError: + continue + + exceptions = sorted(summary.exception(), key=lambda result: result.original_size) + + for entry in exceptions: + exceptions_found = True + yield f'| {entry.corpus_entry} | {python_version} | {entry.outcome} |' + + if not exceptions_found: + yield ' None | | |' + +def report_larger_than_base(results_dir: str, python_versions: str, minifier_sha: str, base_sha: str) -> str: + yield ''' +## Top 10 Larger than base + +| Corpus Entry | Original Size | Minified Size | +|--------------|--------------:|--------------:|''' + + there_are_some_larger_than_base = False + + for python_version in python_versions: + try: + summary = result_summary(results_dir, python_version, minifier_sha) + except FileNotFoundError: + continue + + base_summary = result_summary(results_dir, python_version, base_sha) + larger_than_original = sorted(summary.compare_size_increase(base_summary), key=lambda result: result.original_size)[:10] + + for entry in larger_than_original: + there_are_some_larger_than_base = True + yield f'| {entry.corpus_entry} | {entry.original_size} | {entry.minified_size} ({entry.minified_size - base_summary.entries[entry.corpus_entry].minified_size:+}) |' + + if not there_are_some_larger_than_base: + yield '| N/A | N/A | N/A |' + +def report_slowest(results_dir: str, python_versions: str, minifier_sha: str) -> str: + yield ''' +## Top 10 Slowest + +| Corpus Entry | Original Size | Minified Size | Time | +|--------------|--------------:|--------------:|-----:|''' + + for python_version in python_versions: + summary = result_summary(results_dir, python_version, minifier_sha) + + for entry in sorted(summary.entries.values(), key=lambda entry: entry.time, reverse=True)[:10]: + yield f'| {entry.corpus_entry} | {entry.original_size} | {entry.minified_size} | {entry.time:.3f} |' def report(results_dir: str, minifier_ref: str, minifier_sha: str, base_ref: str, base_sha: str) -> Iterable[str]: """ @@ -236,50 +351,11 @@ def format_size_change_detail() -> str: ) if ENHANCED_REPORT: - yield ''' -## Larger than original - -| Corpus Entry | Original Size | Minified Size | -|--------------|--------------:|--------------:|''' - - for python_version in ['3.11']: - summary = result_summary(results_dir, python_version, minifier_sha) - larger_than_original = sorted(summary.larger_than_original(), key=lambda result: result.original_size) - - for entry in larger_than_original: - yield f'| {entry.corpus_entry} | {entry.original_size} | {entry.minified_size} ({entry.minified_size - entry.original_size:+}) |' - - yield ''' -## Top 10 Larger than base - -| Corpus Entry | Original Size | Minified Size | -|--------------|--------------:|--------------:|''' - - there_are_some_larger_than_base = False - - for python_version in ['3.11']: - summary = result_summary(results_dir, python_version, minifier_sha) - base_summary = result_summary(results_dir, python_version, base_sha) - larger_than_original = sorted(summary.compare_size_increase(base_summary), key=lambda result: result.original_size)[:10] - - for entry in larger_than_original: - there_are_some_larger_than_base = True - yield f'| {entry.corpus_entry} | {entry.original_size} | {entry.minified_size} ({entry.minified_size - base_summary.entries[entry.corpus_entry].minified_size:+}) |' - - if not there_are_some_larger_than_base: - yield '| N/A | N/A | N/A |' - - yield ''' -## Top 10 Slowest - -| Corpus Entry | Original Size | Minified Size | Time | -|--------------|--------------:|--------------:|-----:|''' - - for python_version in ['3.11']: - summary = result_summary(results_dir, python_version, minifier_sha) - - for entry in sorted(summary.entries.values(), key=lambda entry: entry.time, reverse=True)[:10]: - yield f'| {entry.corpus_entry} | {entry.original_size} | {entry.minified_size} | {entry.time:.3f} |' + yield from report_larger_than_original(results_dir, ['3.11'], minifier_sha) + yield from report_larger_than_base(results_dir, ['3.11'], minifier_sha, base_sha) + yield from report_slowest(results_dir, ['3.11'], minifier_sha) + yield from report_unstable(results_dir, ['2.7', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11'], minifier_sha) + yield from report_exceptions(results_dir, ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'], minifier_sha) def main(): diff --git a/corpus_test/generate_results.py b/corpus_test/generate_results.py index f43d4ad4..a8c3fdf9 100644 --- a/corpus_test/generate_results.py +++ b/corpus_test/generate_results.py @@ -1,8 +1,14 @@ import argparse +import datetime +import gzip import os import sys import time + +import logging + + import python_minifier from result import Result, ResultWriter @@ -23,8 +29,13 @@ def minify_corpus_entry(corpus_path, corpus_entry): :rtype: Result """ - with open(os.path.join(corpus_path, corpus_entry), 'rb') as f: - source = f.read() + if os.path.isfile(os.path.join(corpus_path, corpus_entry + '.py.gz')): + with gzip.open(os.path.join(corpus_path, corpus_entry + '.py.gz'), 'rb') as f: + source = f.read() + else: + with open(os.path.join(corpus_path, corpus_entry), 'rb') as f: + source = f.read() + result = Result(corpus_entry, len(source), 0, 0, '') @@ -72,21 +83,54 @@ def corpus_test(corpus_path, results_path, sha, regenerate_results): :param str sha: The python-minifier sha we are testing :param bool regenerate_results: Regenerate results even if they are present """ - corpus_entries = os.listdir(corpus_path) - python_version = '.'.join([str(s) for s in sys.version_info[:2]]) + + log_path = 'results_' + python_version + '_' + sha + '.log' + print('Logging in GitHub Actions is absolute garbage. Logs are going to ' + log_path) + + logging.basicConfig(filename=os.path.join(results_path, log_path), level=logging.DEBUG) + + corpus_entries = [entry[:-len('.py.gz')] for entry in os.listdir(corpus_path)] + results_file_path = os.path.join(results_path, 'results_' + python_version + '_' + sha + '.csv') - if os.path.isfile(results_file_path) and not regenerate_results: - print('Results file already exists: %s', results_file_path) - return + if os.path.isfile(results_file_path): + logging.info('Results file already exists: %s', results_file_path) + if regenerate_results: + os.remove(results_file_path) + + total_entries = len(corpus_entries) + logging.info('Testing python-minifier on %d entries' % total_entries) + tested_entries = 0 + + start_time = time.time() + next_checkpoint = time.time() + 60 with ResultWriter(results_file_path) as result_writer: + logging.info('%d results already present' % len(result_writer)) + for entry in corpus_entries: - print(entry) + if entry in result_writer: + continue + + logging.debug(entry) + result = minify_corpus_entry(corpus_path, entry) result_writer.write(result) + tested_entries += 1 + + sys.stdout.flush() + + if time.time() > next_checkpoint: + percent = len(result_writer) / total_entries * 100 + time_per_entry = (time.time() - start_time) / tested_entries + entries_remaining = len(corpus_entries) - len(result_writer) + time_remaining = int(entries_remaining * time_per_entry) + logging.info('Tested %d/%d entries (%d%%) %s seconds remaining' % (len(result_writer), total_entries, percent, time_remaining)) + sys.stdout.flush() + next_checkpoint = time.time() + 60 + logging.info('Finished') def bool_parse(value): return value == 'true' diff --git a/corpus_test/result.py b/corpus_test/result.py index 00123dc8..b02e4ac1 100644 --- a/corpus_test/result.py +++ b/corpus_test/result.py @@ -1,3 +1,6 @@ +import os + + class Result(object): def __init__(self, corpus_entry, original_size, minified_size, time, outcome): @@ -21,15 +24,37 @@ def __init__(self, results_path): :param str results_path: The path to the results file """ self._results_path = results_path + self._size = 0 + self._existing_result_set = set() + + if not os.path.isfile(self._results_path): + return + + with open(self._results_path, 'r') as f: + for line in f: + if line != 'corpus_entry,original_size,minified_size,time,result\n': + self._existing_result_set.add(line.split(',')[0]) + + self._size += len(self._existing_result_set) def __enter__(self): - self.results = open(self._results_path, 'w') + self.results = open(self._results_path, 'a') self.results.write('corpus_entry,original_size,minified_size,time,result\n') return self def __exit__(self, exc_type, exc_val, exc_tb): self.results.close() + def __contains__(self, item): + """ + :param str item: The name of the entry in the corpus + :return bool: True if the entry already exists in the results file + """ + return item in self._existing_result_set + + def __len__(self): + return self._size + def write(self, result): """ :param Result result: The result to write to the file @@ -41,6 +66,7 @@ def write(self, result): str(result.time) + ',' + result.outcome + '\n' ) self.results.flush() + self._size += 1 class ResultReader: @@ -66,7 +92,11 @@ def __next__(self): """ :return Result: The next result in the file """ + line = self.results.readline() + while line == 'corpus_entry,original_size,minified_size,time,result\n': + line = self.results.readline() + if line == '': raise StopIteration else: diff --git a/docs/source/transforms/remove_explicit_return_none.py b/docs/source/transforms/remove_explicit_return_none.py new file mode 100644 index 00000000..4cbb4eef --- /dev/null +++ b/docs/source/transforms/remove_explicit_return_none.py @@ -0,0 +1,7 @@ +def important(a): + if a > 3: + return a + if a < 2: + return None + a.adjust(1) + return None diff --git a/docs/source/transforms/remove_explicit_return_none.rst b/docs/source/transforms/remove_explicit_return_none.rst new file mode 100644 index 00000000..47d09171 --- /dev/null +++ b/docs/source/transforms/remove_explicit_return_none.rst @@ -0,0 +1,24 @@ +Remove Explicit Return None +=========================== + +This transforms any ``return None`` statement into a ``return`` statement. +A return statement with no value is equivalent to ``return None``. + +Removes any ``return None`` or ``return`` statements that are the last statement in a function. + +The transform is always safe to use and enabled by default. Disable by passing the ``remove_explicit_return_none=False`` argument to the :func:`python_minifier.minify` function, +or passing ``--no-remove-explicit-remove-none`` to the pyminify command. + +Example +------- + +Input +~~~~~ + +.. literalinclude:: remove_explicit_return_none.py + +Output +~~~~~~ + +.. literalinclude:: remove_explicit_return_none.min.py + :language: python diff --git a/src/python_minifier/__init__.py b/src/python_minifier/__init__.py index 3560b416..a543f7e7 100644 --- a/src/python_minifier/__init__.py +++ b/src/python_minifier/__init__.py @@ -23,6 +23,7 @@ from python_minifier.transforms.remove_annotations import RemoveAnnotations from python_minifier.transforms.remove_asserts import RemoveAsserts from python_minifier.transforms.remove_debug import RemoveDebug +from python_minifier.transforms.remove_explicit_return_none import RemoveExplicitReturnNone from python_minifier.transforms.remove_literal_statements import RemoveLiteralStatements from python_minifier.transforms.remove_object_base import RemoveObject from python_minifier.transforms.remove_pass import RemovePass @@ -64,7 +65,8 @@ def minify( convert_posargs_to_args=True, preserve_shebang=True, remove_asserts=False, - remove_debug=False + remove_debug=False, + remove_explicit_return_none=True, ): """ Minify a python module @@ -94,6 +96,7 @@ def minify( :param bool preserve_shebang: Keep any shebang interpreter directive from the source in the minified output :param bool remove_asserts: If assert statements should be removed :param bool remove_debug: If conditional statements that test '__debug__ is True' should be removed + :param bool remove_explicit_return_none: If explicit return None statements should be replaced with a bare return :rtype: str @@ -127,6 +130,9 @@ def minify( if remove_debug: module = RemoveDebug()(module) + if remove_explicit_return_none: + module = RemoveExplicitReturnNone()(module) + bind_names(module) resolve_names(module) diff --git a/src/python_minifier/__init__.pyi b/src/python_minifier/__init__.pyi index 94987256..cd1c77a0 100644 --- a/src/python_minifier/__init__.pyi +++ b/src/python_minifier/__init__.pyi @@ -21,7 +21,8 @@ def minify( convert_posargs_to_args: bool = ..., preserve_shebang: bool = ..., remove_asserts: bool = ..., - remove_debug: bool = ... + remove_debug: bool = ..., + remove_explicit_return_none: bool = ... ) -> Text: ... def unparse(module: ast.Module) -> Text: ... diff --git a/src/python_minifier/__main__.py b/src/python_minifier/__main__.py index e3272242..9547a81e 100644 --- a/src/python_minifier/__main__.py +++ b/src/python_minifier/__main__.py @@ -183,7 +183,12 @@ def parse_args(): help='Remove conditional statements that test __debug__ is True', dest='remove_debug', ) - + minification_options.add_argument( + '--no-remove-explicit-return-none', + action='store_false', + help='Replace explicit return None with a bare return', + dest='remove_explicit_return_none', + ) parser.add_argument('--version', '-v', action='version', version=version) args = parser.parse_args() @@ -248,7 +253,8 @@ def do_minify(source, filename, minification_args): convert_posargs_to_args=minification_args.convert_posargs_to_args, preserve_shebang=minification_args.preserve_shebang, remove_asserts=minification_args.remove_asserts, - remove_debug=minification_args.remove_debug + remove_debug=minification_args.remove_debug, + remove_explicit_return_none=minification_args.remove_explicit_return_none, ) diff --git a/src/python_minifier/transforms/remove_explicit_return_none.py b/src/python_minifier/transforms/remove_explicit_return_none.py new file mode 100644 index 00000000..2148dbcc --- /dev/null +++ b/src/python_minifier/transforms/remove_explicit_return_none.py @@ -0,0 +1,38 @@ +import ast +import sys + +from python_minifier.transforms.suite_transformer import SuiteTransformer +from python_minifier.util import is_ast_node + + +class RemoveExplicitReturnNone(SuiteTransformer): + def __call__(self, node): + return self.visit(node) + + def visit_Return(self, node): + assert isinstance(node, ast.Return) + + # Transform `return None` -> `return` + + if sys.version_info < (3, 4) and isinstance(node.value, ast.Name) and node.value.id == 'None': + node.value = None + + elif sys.version_info >= (3, 4) and is_ast_node(node.value, 'NameConstant') and node.value.value is None: + node.value = None + + return node + + def visit_FunctionDef(self, node): + assert is_ast_node(node, (ast.FunctionDef, 'AsyncFunctionDef')) + + node.body = [self.visit(a) for a in node.body] + + # Remove an explicit valueless `return` from the end of a function + if len(node.body) > 0 and isinstance(node.body[-1], ast.Return) and node.body[-1].value is None: + node.body.pop() + + # Replace empty suites with `0` expression statements + if len(node.body) == 0: + node.body = [self.add_child(ast.Expr(value=ast.Num(0)), parent=node)] + + return node diff --git a/test/test_remove_explicit_return_none.py b/test/test_remove_explicit_return_none.py new file mode 100644 index 00000000..a5e8ebf9 --- /dev/null +++ b/test/test_remove_explicit_return_none.py @@ -0,0 +1,218 @@ +import ast +import sys + +import pytest + +from python_minifier import unparse +from python_minifier.ast_compare import compare_ast +from python_minifier.transforms.remove_explicit_return_none import RemoveExplicitReturnNone + + +def remove_return_none(source): + module = ast.parse(source, 'remove_return_none') + + return RemoveExplicitReturnNone()(module) + + +def test_trailing_remove_return_none(): + source = 'def a():a=4;return None' + expected = 'def a():a=4' + + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + + assert unparse(actual_ast) == expected + + +def test_trailing_implicit_return_none(): + source = 'def a():a=4;return' + expected = 'def a():a=4' + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected + + +def test_trailing_remove_return_none_empty_suite(): + source = 'def a():return None' + expected = 'def a():0' + + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + + assert unparse(actual_ast) == expected + + +def test_trailing_implicit_return_none_empty_suite(): + source = 'def a():return' + expected = 'def a():0' + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected + + +def test_trailing_return_value_unchanged(): + source = 'def a():return 0' + expected = source + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected + + +def test_remove_return_none(): + source = ''' +def a(): + if a: return None + return None +''' + expected = 'def a():\n\tif a:return' + + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + + assert unparse(actual_ast) == expected + + +def test_implicit_return_none(): + source = ''' +def a(): + if a: return + return +''' + expected = 'def a():\n\tif a:return' + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected + + +def test_return_value_unchanged(): + source = ''' +def a(): + if a: return 1 + return 3 +''' + expected = 'def a():\n\tif a:return 1\n\treturn 3' + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected + + +def test_async_trailing_remove_return_none(): + if sys.version_info < (3, 5): + pytest.skip('Async not allowed in python < 3.5') + + source = 'async def a():a=4;return None' + expected = 'async def a():a=4' + + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + + assert unparse(actual_ast) == expected + + +def test_async_trailing_implicit_return_none(): + if sys.version_info < (3, 5): + pytest.skip('Async not allowed in python < 3.5') + + source = 'async def a():a=4;return' + expected = 'async def a():a=4' + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected + + +def test_async_trailing_remove_return_none_empty_suite(): + if sys.version_info < (3, 5): + pytest.skip('Async not allowed in python < 3.5') + + source = 'async def a():return None' + expected = 'async def a():0' + + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + + assert unparse(actual_ast) == expected + + +def test_async_trailing_implicit_return_none_empty_suite(): + if sys.version_info < (3, 5): + pytest.skip('Async not allowed in python < 3.5') + + source = 'async def a():return' + expected = 'async def a():0' + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected + + +def test_async_trailing_return_value_unchanged(): + if sys.version_info < (3, 5): + pytest.skip('Async not allowed in python < 3.5') + + source = 'async def a():return 0' + expected = source + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected + + +def test_async_remove_return_none(): + if sys.version_info < (3, 5): + pytest.skip('Async not allowed in python < 3.5') + + source = ''' +async def a(): + if a: return None + return None +''' + expected = 'async def a():\n\tif a:return' + + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + + assert unparse(actual_ast) == expected + + +def test_async_implicit_return_none(): + if sys.version_info < (3, 5): + pytest.skip('Async not allowed in python < 3.5') + + source = ''' +async def a(): + if a: return + return +''' + expected = 'async def a():\n\tif a:return' + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected + + +def test_async_return_value_unchanged(): + if sys.version_info < (3, 5): + pytest.skip('Async not allowed in python < 3.5') + + source = ''' +async def a(): + if a: return 1 + return 3 +''' + expected = 'async def a():\n\tif a:return 1\n\treturn 3' + expected_ast = ast.parse(expected) + actual_ast = remove_return_none(source) + compare_ast(expected_ast, actual_ast) + assert unparse(actual_ast) == expected