diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2abb86ed..effad2e6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,7 @@ Changes like class methods and properties * ``LineProfiler`` can now be used as a class decorator * FIX: Fixed line tracing for Cython code; superseded use of the legacy tracing system with ``sys.monitoring`` +* ENH: Fixed edge case where :py:meth:`LineProfiler.get_stats()` neglects data from duplicate code objects (#348) 4.2.0 ~~~~~ diff --git a/line_profiler/_line_profiler.pyx b/line_profiler/_line_profiler.pyx index 15510317..db2ff852 100644 --- a/line_profiler/_line_profiler.pyx +++ b/line_profiler/_line_profiler.pyx @@ -530,35 +530,29 @@ cdef class LineProfiler: """ cdef dict cmap = self._c_code_map - stats = {} + all_entries = {} for code in self.code_hash_map: entries = [] for entry in self.code_hash_map[code]: - entries += list(cmap[entry].values()) + entries.extend(cmap[entry].values()) key = label(code) - # Merge duplicate line numbers, which occur for branch entrypoints like `if` - nhits_by_lineno = {} - total_time_by_lineno = {} + # Merge duplicate line numbers, which occur for branch + # entrypoints like `if` + entries_by_lineno = all_entries.setdefault(key, {}) for line_dict in entries: _, lineno, total_time, nhits = line_dict.values() - nhits_by_lineno[lineno] = nhits_by_lineno.setdefault(lineno, 0) + nhits - total_time_by_lineno[lineno] = total_time_by_lineno.setdefault(lineno, 0) + total_time - - entries = [(lineno, nhits, total_time_by_lineno[lineno]) for lineno, nhits in nhits_by_lineno.items()] - entries.sort() - - # NOTE: v4.x may produce more than one entry per line. For example: - # 1: for x in range(10): - # 2: pass - # will produce a 1-hit entry on line 1, and 10-hit entries on lines 1 and 2 - # This doesn't affect `print_stats`, because it uses the last entry for a given line (line number is - # used a dict key so earlier entries are overwritten), but to keep compatability with other tools, - # let's only keep the last entry for each line - # Remove all but the last entry for each line - entries = list({e[0]: e for e in entries}.values()) - stats[key] = entries + orig_nhits, orig_total_time = entries_by_lineno.get( + lineno, (0, 0)) + entries_by_lineno[lineno] = (orig_nhits + nhits, + orig_total_time + total_time) + + # Aggregate the timing data + stats = { + key: sorted((line, nhits, time) + for line, (nhits, time) in entries_by_lineno.items()) + for key, entries_by_lineno in all_entries.items()} return LineStats(stats, self.timer_unit) diff --git a/tests/test_line_profiler.py b/tests/test_line_profiler.py index 954c77b1..40ec3199 100644 --- a/tests/test_line_profiler.py +++ b/tests/test_line_profiler.py @@ -781,3 +781,37 @@ def sum_n_cb(n): assert t2['sum_n'][2][1] == n assert t1['sum_n_sq'][2][1] == n assert t2['sum_n_cb'][2][1] == n + + +def test_duplicate_code_objects(): + """ + Test that results are correctly aggregated between duplicate code + objects. + """ + code = textwrap.dedent(""" + @profile + def func(n): + x = 0 + for n in range(1, n + 1): + x += n + return x + """).strip('\n') + profile = LineProfiler() + # Create and call the function once + namespace_1 = {'profile': profile} + exec(code, namespace_1) + assert 'func' in namespace_1 + assert len(profile.functions) == 1 + assert namespace_1['func'].__wrapped__ in profile.functions + assert namespace_1['func'](10) == 10 * 11 // 2 + # Do it again + namespace_2 = {'profile': profile} + exec(code, namespace_2) + assert 'func' in namespace_2 + assert len(profile.functions) == 2 + assert namespace_2['func'].__wrapped__ in profile.functions + assert namespace_2['func'](20) == 20 * 21 // 2 + # Check that data from both calls are aggregated + # (Entries are represented as tuples `(lineno, nhits, time)`) + entries, = profile.get_stats().timings.values() + assert entries[-2][1] == 10 + 20