Skip to content

Commit

Permalink
Merge 04bb1a5 into 0cfed46
Browse files Browse the repository at this point in the history
  • Loading branch information
d-maurer committed May 31, 2022
2 parents 0cfed46 + 04bb1a5 commit b92ecec
Show file tree
Hide file tree
Showing 11 changed files with 669 additions and 8 deletions.
10 changes: 10 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@
5.4.1 (unreleased)
==================

- New option ``--gc-after-test``. It calls for a garbage collection
after each test and can be used to track down ``ResourceWarning``s
and cyclic garbage.
With ``rv = gc.collect()``, ``!`` is output on verbosity level 1 when
``rv`` is non zero (i.e. when cyclic structures have been released),
``[``*rv*``]`` on higher verbosity levels and
a detailed cyclic garbage analysis on verbosity level 4+.
For details, see
`#133 <https://github.com/zopefoundation/zope.testrunner/pull/133`_.

- Allow the filename for the logging configuration to be specified
via the envvar ``ZOPE_TESTRUNNER_LOG_INI``.
If not defined, the configuration continues to be locked for
Expand Down
199 changes: 199 additions & 0 deletions src/zope/testrunner/digraph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
##############################################################################
#
# Copyright (c) 2022 Zope Foundation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
#
##############################################################################
"""Directed graph
"""

from itertools import count


class DiGraph(object):
"""Directed graph.
A directed graph is a set of nodes together with a
neighboring relation.
The class makes intensive use of dicts; therefore, hashability
is important. Therefore, the class usually does not work
with the nodes directly but transforms them via
a ``make_hashable`` function, ``id`` by default.
This works well for object types where equality is identity.
For other types, you may need to deactive the transformation
or use a different ``make_hashable``.
"""

def __init__(self, nodes=None, make_hashable=id):
self._nodes = set() # transformed nodes
self._neighbors = {} # node --> neighbors -- transformed
if make_hashable:
tr2n = {} # transform -> node

tr_node = make_hashable

def utr_node(node):
return tr2n[node]

def tr_nodes(nodes):
ns = set()
add = ns.add
for n in nodes:
trn = make_hashable(n)
if trn not in tr2n:
tr2n[trn] = n
add(trn)
return ns

else:
tr_nodes = lambda nodes: set(nodes) # noqa: E731
utr_node = tr_node = lambda node: node

self._transform_node = tr_node
self._transform_nodes = tr_nodes
self._untransform_node = utr_node

if nodes is not None:
self.add_nodes(nodes)

def add_nodes(self, nodes):
"""add *nodes* (iterator) to the graph's nodes."""
self._nodes |= self._transform_nodes(nodes)

def add_neighbors(self, node, neighbors, ignore_unknown=True):
"""add *neighbors* (iterator) as neighbors for *node*.
if *ignore_unknown*, unknown nodes in *neighbors* are
ignored, otherwise a ``KeyError`` is raised.
"""
tr_n = self._transform_node(node)
nodes = self._nodes
nbad = tr_n not in nodes
if nbad:
if ignore_unknown:
return
else:
raise KeyError(node)
tr_neighbors = self._transform_nodes(neighbors)
known_neighbors = tr_neighbors & nodes
if not ignore_unknown and len(known_neighbors) != len(tr_neighbors):
raise KeyError(tr_neighbors - known_neighbors)
nbs = self._neighbors.get(tr_n)
if nbs is None:
self._neighbors[tr_n] = known_neighbors
else:
nbs |= known_neighbors

def nodes(self):
"""iterate of the graph's nodes."""
utr = self._untransform_node
for n in self._nodes:
yield utr(n)

def neighbors(self, node):
"""iterate over *node*'s neighbors."""
utr = self._untransform_node
for n in self._neighbors.get(self._transform_node(node), ()):
yield utr(n)

def sccs(self, trivial=False):
"""iteratate over the strongly connected components.
If *trivial*, include the trivial components; otherwise
only the cycles.
This is an implementation of the "Tarjan SCC" algorithm.
"""

# any node is either in ``unvisited`` or in ``state``
unvisited = self._nodes.copy()
state = {} # nodes -> state

ancestors = [] # the ancestors of the currently processed node
stack = [] # the nodes which might still be on a cycle

# the algorithm visits each node twice in a depth first order
# In the first visit, visits for the unprocessed neighbors
# are scheduled as well as the second visit to this
# node after all neighbors have been processed.
dfs = count() # depth first search visit order
rtn_marker = object() # marks second visit to ``ancestor`` top
visits = [] # scheduled visits

while unvisited:
node = next(iter(unvisited))
# determine the depth first spanning tree rooted in *node*
visits.append(node)
while visits:
visit = visits[-1] # ``rtn_marker`` or node
if visit is rtn_marker:
# returned to the top of ``ancestors``
visits.pop()
node = ancestors.pop() # returned to *node*
nstate = state[node]
if nstate.low == nstate.dfs:
# SCC root
scc = []
while True:
n = stack.pop()
state[n].stacked = False
scc.append(n)
if n is node:
break
if len(scc) == 1 and not trivial:
# check for triviality
n = scc[0]
if n not in self._neighbors[n]:
continue # tivial -- ignore
utr = self._untransform_node
yield [utr(n) for n in scc]
if not ancestors:
# dfs tree determined
assert not visits
break
pstate = state[ancestors[-1]]
nstate = state[node]
low = nstate.low
if low < pstate.low:
pstate.low = low
else: # scheduled first visit
node = visit
nstate = state.get(node)
if nstate is not None:
# we have already been visited
if nstate.stacked:
# update parent
pstate = state[ancestors[-1]]
if nstate.dfs < pstate.low:
pstate.low = nstate.dfs
visits.pop()
continue
unvisited.remove(node)
nstate = state[node] = _TarjanState(dfs)
ancestors.append(node)
stack.append(node)
nstate.stacked = True
visits[-1] = rtn_marker # schedule return visit
# schedule neighbor visits
visits.extend(self._neighbors.get(node, ()))


class _TarjanState(object):
"""representation of a node's processing state."""
__slots__ = "stacked dfs low".split()

def __init__(self, dfs):
self.stacked = False
self.dfs = self.low = next(dfs)

def __repr__(self):
return "dfs=%d low=%d stacked=%s" \
% (self.dfs, self.low, self.stacked)
22 changes: 20 additions & 2 deletions src/zope/testrunner/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,16 @@ def test_threads(self, test, new_threads):
print(test)
print("New thread(s):", new_threads)

def test_cycles(self, test, cycles):
"""Report cyclic garbage left behind by a test."""
if cycles:
print("The following test left cyclic garbage behind:")
print(test)
for i, cy in enumerate(cycles):
print("Cycle", i + 1)
for oi in cy:
print(" * ", "\n ".join(oi))

def refcounts(self, rc, prev):
"""Report a change in reference counts."""
print(" sys refcount=%-8d change=%-6d" % (rc, rc - prev))
Expand Down Expand Up @@ -395,8 +405,12 @@ def format_traceback(self, exc_info):
tb = "".join(traceback.format_exception(*exc_info))
return tb

def stop_test(self, test):
def stop_test(self, test, gccount):
"""Clean up the output state after a test."""
if gccount and self.verbose:
s = "!" if self.verbose == 1 else " [%d]" % gccount
self.test_width += len(s)
print(s, end="")
if self.progress:
self.last_width = self.test_width
elif self.verbose > 1:
Expand Down Expand Up @@ -1106,6 +1120,10 @@ def test_threads(self, test, new_threads):
test, details={'threads': text_content(unicode(new_threads))})
self._subunit.stopTest(test)

def test_cycles(self, test, cycles):
"""Report cycles left behind by a test."""
pass # not implemented

def refcounts(self, rc, prev):
"""Report a change in reference counts."""
details = _SortedDict({
Expand Down Expand Up @@ -1293,7 +1311,7 @@ def test_failure(self, test, seconds, exc_info, stdout=None, stderr=None):
self._add_std_streams_to_details(details, stdout, stderr)
self._subunit.addFailure(test, details=details)

def stop_test(self, test):
def stop_test(self, test, gccount):
"""Clean up the output state after a test."""
self._subunit.stopTest(test)

Expand Down
12 changes: 12 additions & 0 deletions src/zope/testrunner/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
from zope.testrunner.formatter import terminal_has_colors
from zope.testrunner.profiling import available_profilers

from .util import uses_refcounts


def _regex_search(s):
return re.compile(s).search
Expand Down Expand Up @@ -341,6 +343,16 @@ def _regex_search(s):
once to set multiple flags.
""")

if uses_refcounts:
analysis.add_argument(
'--gc-after-test', action="store_true", dest='gc_after_test',
help="""\
After each test, call 'gc.collect' and record the return
value *rv*; when *rv* is non-zero, output '!' on verbosity level 1
and '[*rv*]' on higher verbosity levels.\n
On verbosity level 4 or higher output detailed cycle information.
""")

analysis.add_argument(
'--repeat', '-N', action="store", type=int, dest='repeat',
default=1,
Expand Down
Loading

0 comments on commit b92ecec

Please sign in to comment.