thread-order 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thread_order/__init__.py +51 -0
- thread_order/graph.py +142 -0
- thread_order/graph_summary.py +313 -0
- thread_order/logger.py +143 -0
- thread_order/runner.py +376 -0
- thread_order/scheduler.py +487 -0
- thread_order/timer.py +31 -0
- thread_order-1.0.0.dist-info/METADATA +329 -0
- thread_order-1.0.0.dist-info/RECORD +13 -0
- thread_order-1.0.0.dist-info/WHEEL +5 -0
- thread_order-1.0.0.dist-info/entry_points.txt +2 -0
- thread_order-1.0.0.dist-info/licenses/LICENSE +201 -0
- thread_order-1.0.0.dist-info/top_level.txt +1 -0
thread_order/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from importlib import metadata as _metadata
|
|
2
|
+
import importlib
|
|
3
|
+
from os import getenv
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
'Scheduler',
|
|
7
|
+
'DAGraph',
|
|
8
|
+
'configure_logging',
|
|
9
|
+
'ThreadProxyLogger',
|
|
10
|
+
'dmark',
|
|
11
|
+
'mark',
|
|
12
|
+
'default_workers',
|
|
13
|
+
'__version__']
|
|
14
|
+
|
|
15
|
+
def __getattr__(name):
|
|
16
|
+
if name == 'Scheduler':
|
|
17
|
+
from .scheduler import Scheduler
|
|
18
|
+
return Scheduler
|
|
19
|
+
if name == 'DAGraph':
|
|
20
|
+
from .graph import DAGraph
|
|
21
|
+
return DAGraph
|
|
22
|
+
if name == 'configure_logging':
|
|
23
|
+
from .logger import configure_logging
|
|
24
|
+
return configure_logging
|
|
25
|
+
if name == 'ThreadProxyLogger':
|
|
26
|
+
from .logger import ThreadProxyLogger
|
|
27
|
+
return ThreadProxyLogger
|
|
28
|
+
if name == 'dmark':
|
|
29
|
+
from .scheduler import dmark
|
|
30
|
+
return dmark
|
|
31
|
+
if name == 'mark':
|
|
32
|
+
from .scheduler import mark
|
|
33
|
+
return mark
|
|
34
|
+
if name == 'default_workers':
|
|
35
|
+
from .scheduler import default_workers
|
|
36
|
+
return default_workers
|
|
37
|
+
# If the requested attribute isn't one of the known top-level symbols,
|
|
38
|
+
# try to lazily import a submodule (e.g. `thread_order.scheduler`) so
|
|
39
|
+
# attribute lookups such as those used by mocking/patching succeed.
|
|
40
|
+
try:
|
|
41
|
+
return importlib.import_module(f"{__name__}.{name}")
|
|
42
|
+
except Exception:
|
|
43
|
+
raise AttributeError(name)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
__version__ = _metadata.version(__name__)
|
|
47
|
+
except _metadata.PackageNotFoundError:
|
|
48
|
+
__version__ = '1.0.0'
|
|
49
|
+
|
|
50
|
+
if getenv('DEV'):
|
|
51
|
+
__version__ = f'{__version__}+dev'
|
thread_order/graph.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import logging
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
def log_candidates(candidates, number):
|
|
6
|
+
""" log a debug message describing how many candidate nodes were found
|
|
7
|
+
"""
|
|
8
|
+
logger = logging.getLogger(threading.current_thread().name)
|
|
9
|
+
count = len(candidates)
|
|
10
|
+
if not candidates:
|
|
11
|
+
message = 'but found no candidates eligible for submission'
|
|
12
|
+
else:
|
|
13
|
+
base = f"and found {count} candidate{'s' if count != 1 else ''} eligible for submission"
|
|
14
|
+
message = f"{base} {', '.join(candidates)}"
|
|
15
|
+
logger.debug(f'requested {number} {message}')
|
|
16
|
+
|
|
17
|
+
class DAGraph:
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
""" initialize an empty DAG with parent and child adjacency mappings
|
|
21
|
+
"""
|
|
22
|
+
self._parents = defaultdict(list)
|
|
23
|
+
self._children = defaultdict(set)
|
|
24
|
+
self._original_parents = {}
|
|
25
|
+
|
|
26
|
+
def add(self, name, after=None):
|
|
27
|
+
""" add a new node with optional dependencies
|
|
28
|
+
|
|
29
|
+
All items in `after` must already exist in the DAG.
|
|
30
|
+
Raises ValueError if the node already exists, dependencies are unknown,
|
|
31
|
+
or the addition would introduce a cycle.
|
|
32
|
+
"""
|
|
33
|
+
logger = logging.getLogger(threading.current_thread().name)
|
|
34
|
+
after = after or []
|
|
35
|
+
logger.debug(f'add {name} dependent on {after}')
|
|
36
|
+
if name in self._parents:
|
|
37
|
+
raise ValueError(f'{name} has already been added')
|
|
38
|
+
unknowns = [dep for dep in after if dep not in self._parents]
|
|
39
|
+
if unknowns:
|
|
40
|
+
raise ValueError(f'{name} depends on unknown {unknowns}')
|
|
41
|
+
self._parents[name] = []
|
|
42
|
+
self._original_parents[name] = list(after) if after else []
|
|
43
|
+
for dep in after:
|
|
44
|
+
self._parents[name].append(dep)
|
|
45
|
+
self._children[dep].add(name)
|
|
46
|
+
# defensive: future refactor may allow updating deps
|
|
47
|
+
if self._has_cycle():
|
|
48
|
+
# rollback this node to keep DAG consistent
|
|
49
|
+
for dep in after:
|
|
50
|
+
self._children[dep].discard(name)
|
|
51
|
+
self._parents.pop(name, None)
|
|
52
|
+
raise ValueError(f'adding {name} will create a cycle')
|
|
53
|
+
|
|
54
|
+
def remove(self, name):
|
|
55
|
+
""" remove a completed node and detach it from all dependent children
|
|
56
|
+
|
|
57
|
+
Cleans up parent and child relationships and drops the node completely
|
|
58
|
+
once it has no remaining edges.
|
|
59
|
+
"""
|
|
60
|
+
logger = logging.getLogger(threading.current_thread().name)
|
|
61
|
+
for child in self._children.pop(name, ()):
|
|
62
|
+
logger.debug(f'removing {name} as a dependency from {child}')
|
|
63
|
+
try:
|
|
64
|
+
self._parents[child].remove(name)
|
|
65
|
+
except ValueError:
|
|
66
|
+
# defensive: graph might already be partially cleaned
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
if name in self._parents and not self._parents[name]:
|
|
70
|
+
logger.debug(f'removing {name} from dependency graph')
|
|
71
|
+
self._parents.pop(name, None)
|
|
72
|
+
|
|
73
|
+
def ready(self, active=None):
|
|
74
|
+
""" return a list of nodes whose dependencies are satisfied and not active
|
|
75
|
+
"""
|
|
76
|
+
if active is None:
|
|
77
|
+
active = set()
|
|
78
|
+
return [name for name, deps in self._parents.items() if not deps and name not in active]
|
|
79
|
+
|
|
80
|
+
def get_candidates(self, active, number, sort=True):
|
|
81
|
+
""" return up to `number` ready nodes, optionally sorted for stable scheduling
|
|
82
|
+
|
|
83
|
+
Also logs the candidate list for visibility.
|
|
84
|
+
"""
|
|
85
|
+
candidates = self.ready(active)
|
|
86
|
+
if sort:
|
|
87
|
+
candidates = sorted(candidates)
|
|
88
|
+
log_candidates(candidates, number)
|
|
89
|
+
return candidates[:number]
|
|
90
|
+
|
|
91
|
+
def _has_cycle(self):
|
|
92
|
+
""" return True if DAGraph contains a cycle
|
|
93
|
+
"""
|
|
94
|
+
visited = set()
|
|
95
|
+
stack = set()
|
|
96
|
+
|
|
97
|
+
def visit(node):
|
|
98
|
+
if node in stack:
|
|
99
|
+
return True
|
|
100
|
+
if node in visited:
|
|
101
|
+
return False
|
|
102
|
+
visited.add(node)
|
|
103
|
+
stack.add(node)
|
|
104
|
+
for neighbor in self._parents[node]:
|
|
105
|
+
if visit(neighbor):
|
|
106
|
+
return True
|
|
107
|
+
stack.remove(node)
|
|
108
|
+
return False
|
|
109
|
+
return any(visit(node) for node in self._parents)
|
|
110
|
+
|
|
111
|
+
def is_empty(self):
|
|
112
|
+
""" return True if the DAGraph has no nodes
|
|
113
|
+
"""
|
|
114
|
+
return not self._parents and not self._children
|
|
115
|
+
|
|
116
|
+
def __repr__(self):
|
|
117
|
+
""" return a human-readable representation of the dependency graph
|
|
118
|
+
"""
|
|
119
|
+
parents = '\n'.join(f'{n}: {self._parents[n]}' for n in sorted(self._parents))
|
|
120
|
+
children = '\n'.join(
|
|
121
|
+
f'{n}: {sorted(list(self._children[n]))}' for n in sorted(self._children))
|
|
122
|
+
return f'Parents:\n{parents}\nChildren:\n{children}'
|
|
123
|
+
|
|
124
|
+
def nodes(self):
|
|
125
|
+
""" return an iterable of node names in the graph
|
|
126
|
+
"""
|
|
127
|
+
return self._parents.keys()
|
|
128
|
+
|
|
129
|
+
def parents_of(self, name):
|
|
130
|
+
""" return a list of parent nodes (dependencies) for a given node
|
|
131
|
+
"""
|
|
132
|
+
return list(self._parents.get(name, []))
|
|
133
|
+
|
|
134
|
+
def children_of(self, name):
|
|
135
|
+
""" return a list of child nodes (dependents) for a given node
|
|
136
|
+
"""
|
|
137
|
+
return list(self._children.get(name, []))
|
|
138
|
+
|
|
139
|
+
def original_parents_of(self, name):
|
|
140
|
+
""" return a list of original parent nodes (dependencies) for a given node
|
|
141
|
+
"""
|
|
142
|
+
return list(self._original_parents.get(name, []))
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Graph summary formatting helpers for threaded_order.
|
|
3
|
+
|
|
4
|
+
This module produces a human-readable summary of a DAGraph instance, used
|
|
5
|
+
by the CLI to display the dependency graph.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def _graph_get_nodes_and_ids(dag):
|
|
9
|
+
""" return a sorted list of node names and a stable numeric ID mapping.
|
|
10
|
+
|
|
11
|
+
IDs are deterministic based on sorted node order.
|
|
12
|
+
Returns:
|
|
13
|
+
nodes: [name, ...]
|
|
14
|
+
ids: {name: numeric_id}
|
|
15
|
+
"""
|
|
16
|
+
nodes = sorted(dag.nodes())
|
|
17
|
+
ids = {}
|
|
18
|
+
for idx, name in enumerate(nodes):
|
|
19
|
+
ids[name] = idx
|
|
20
|
+
return nodes, ids
|
|
21
|
+
|
|
22
|
+
def _graph_build_indegree_and_adj(dag, nodes):
|
|
23
|
+
""" build indegree table and adjacency (outgoing edges) table.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
indegree: {node: number_of_parents}
|
|
27
|
+
adj: {node: [sorted_child_nodes]}
|
|
28
|
+
num_edges: total edge count
|
|
29
|
+
"""
|
|
30
|
+
indegree = {}
|
|
31
|
+
adj = {}
|
|
32
|
+
num_edges = 0
|
|
33
|
+
|
|
34
|
+
for name in nodes:
|
|
35
|
+
parents = dag.parents_of(name)
|
|
36
|
+
children = dag.children_of(name)
|
|
37
|
+
|
|
38
|
+
indegree[name] = len(parents)
|
|
39
|
+
children_sorted = sorted(children) if children else []
|
|
40
|
+
adj[name] = children_sorted
|
|
41
|
+
num_edges += len(children_sorted)
|
|
42
|
+
|
|
43
|
+
return indegree, adj, num_edges
|
|
44
|
+
|
|
45
|
+
def _graph_find_roots_and_leaves(nodes, indegree, adj):
|
|
46
|
+
""" identify root nodes (no parents) and leaf nodes (no children).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
roots: [node, ...]
|
|
50
|
+
leaves: [node, ...]
|
|
51
|
+
"""
|
|
52
|
+
roots = []
|
|
53
|
+
leaves = []
|
|
54
|
+
|
|
55
|
+
for name in nodes:
|
|
56
|
+
if indegree[name] == 0:
|
|
57
|
+
roots.append(name)
|
|
58
|
+
if not adj[name]:
|
|
59
|
+
leaves.append(name)
|
|
60
|
+
|
|
61
|
+
return roots, leaves
|
|
62
|
+
|
|
63
|
+
def _graph_compute_levels(nodes, roots, indegree, adj, ids):
|
|
64
|
+
""" compute topological levels using a Kahn-style layering algorithm.
|
|
65
|
+
|
|
66
|
+
Each level contains nodes that can run in parallel after prior levels complete.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
levels: [ [node1, node2], [node3], ... ]
|
|
70
|
+
"""
|
|
71
|
+
levels = []
|
|
72
|
+
if not nodes:
|
|
73
|
+
return levels
|
|
74
|
+
|
|
75
|
+
indeg = {}
|
|
76
|
+
for name, value in indegree.items():
|
|
77
|
+
indeg[name] = value
|
|
78
|
+
|
|
79
|
+
queue = sorted(roots, key=lambda n: ids[n])
|
|
80
|
+
seen = set()
|
|
81
|
+
|
|
82
|
+
while queue:
|
|
83
|
+
level = []
|
|
84
|
+
next_queue = []
|
|
85
|
+
|
|
86
|
+
for name in queue:
|
|
87
|
+
if name in seen:
|
|
88
|
+
continue
|
|
89
|
+
seen.add(name)
|
|
90
|
+
level.append(name)
|
|
91
|
+
|
|
92
|
+
for dst in adj[name]:
|
|
93
|
+
indeg[dst] -= 1
|
|
94
|
+
if indeg[dst] == 0:
|
|
95
|
+
next_queue.append(dst)
|
|
96
|
+
|
|
97
|
+
if level:
|
|
98
|
+
level.sort(key=lambda n: ids[n]) # noqa: E731
|
|
99
|
+
levels.append(level)
|
|
100
|
+
|
|
101
|
+
queue = sorted(next_queue, key=lambda n: ids[n])
|
|
102
|
+
|
|
103
|
+
if not levels:
|
|
104
|
+
levels = [nodes]
|
|
105
|
+
|
|
106
|
+
return levels
|
|
107
|
+
|
|
108
|
+
def _graph_format_header(num_nodes, num_edges, roots, leaves, levels_count, ids):
|
|
109
|
+
""" format the summary header section:
|
|
110
|
+
Graph: X nodes, Y edges
|
|
111
|
+
Roots: [id], [id], ...
|
|
112
|
+
Leaves: [id], [id], ...
|
|
113
|
+
Levels: Z
|
|
114
|
+
"""
|
|
115
|
+
lines = []
|
|
116
|
+
lines.append(f'Graph: {num_nodes} nodes, {num_edges} edges')
|
|
117
|
+
|
|
118
|
+
if roots:
|
|
119
|
+
root_ids = ', '.join(f'[{ids[name]}]' for name in roots)
|
|
120
|
+
lines.append(f'Roots: {root_ids}')
|
|
121
|
+
|
|
122
|
+
if leaves:
|
|
123
|
+
leaf_ids = ', '.join(f'[{ids[name]}]' for name in leaves)
|
|
124
|
+
lines.append(f'Leaves: {leaf_ids}')
|
|
125
|
+
|
|
126
|
+
lines.append(f'Levels: {levels_count}')
|
|
127
|
+
return lines
|
|
128
|
+
|
|
129
|
+
def _graph_format_nodes(nodes, ids):
|
|
130
|
+
""" format the Nodes: section:
|
|
131
|
+
[id] node_name
|
|
132
|
+
"""
|
|
133
|
+
lines = ['Nodes:']
|
|
134
|
+
for name in nodes:
|
|
135
|
+
lines.append(f' [{ids[name]}] {name}')
|
|
136
|
+
return lines
|
|
137
|
+
|
|
138
|
+
def _graph_format_edges(nodes, adj, ids):
|
|
139
|
+
""" format the Edges: section:
|
|
140
|
+
[src_id] -> [child_id], ...
|
|
141
|
+
or
|
|
142
|
+
[src_id] -> (none)
|
|
143
|
+
"""
|
|
144
|
+
lines = ['Edges:']
|
|
145
|
+
for name in nodes:
|
|
146
|
+
children = adj[name]
|
|
147
|
+
if children:
|
|
148
|
+
targets = ', '.join(f'[{ids[c]}]' for c in children)
|
|
149
|
+
else:
|
|
150
|
+
targets = '(none)'
|
|
151
|
+
lines.append(f' [{ids[name]}] -> {targets}')
|
|
152
|
+
return lines
|
|
153
|
+
|
|
154
|
+
def _graph_compute_longest_chains(nodes, levels, adj):
|
|
155
|
+
""" compute the longest dependency chains in the DAG.
|
|
156
|
+
|
|
157
|
+
Uses the topological levels to derive a topo order, then computes the
|
|
158
|
+
longest distance (in edges) from any root to each node.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
max_len: int, length in edges of the longest chain
|
|
162
|
+
chains: list of chains, each a [node, node, ...] list
|
|
163
|
+
"""
|
|
164
|
+
if not nodes:
|
|
165
|
+
return 0, []
|
|
166
|
+
|
|
167
|
+
topo_order = []
|
|
168
|
+
for level in levels:
|
|
169
|
+
for name in level:
|
|
170
|
+
topo_order.append(name)
|
|
171
|
+
|
|
172
|
+
dist = {name: 0 for name in nodes}
|
|
173
|
+
prev = {name: None for name in nodes}
|
|
174
|
+
|
|
175
|
+
for src in topo_order:
|
|
176
|
+
for dst in adj[src]:
|
|
177
|
+
cand = dist[src] + 1
|
|
178
|
+
if cand > dist[dst]:
|
|
179
|
+
dist[dst] = cand
|
|
180
|
+
prev[dst] = src
|
|
181
|
+
|
|
182
|
+
if not dist:
|
|
183
|
+
return 0, []
|
|
184
|
+
|
|
185
|
+
max_len = max(dist.values())
|
|
186
|
+
if max_len == 0:
|
|
187
|
+
# no edges; treat single nodes as trivial chains
|
|
188
|
+
return 0, [[nodes[0]]] if nodes else []
|
|
189
|
+
|
|
190
|
+
end_nodes = [name for name, d in dist.items() if d == max_len]
|
|
191
|
+
|
|
192
|
+
chains = []
|
|
193
|
+
for end in end_nodes:
|
|
194
|
+
chain = []
|
|
195
|
+
cur = end
|
|
196
|
+
while cur is not None:
|
|
197
|
+
chain.append(cur)
|
|
198
|
+
cur = prev[cur]
|
|
199
|
+
chains.append(list(reversed(chain)))
|
|
200
|
+
|
|
201
|
+
# keep it readable: limit to a few longest chains
|
|
202
|
+
return max_len, chains[:3]
|
|
203
|
+
|
|
204
|
+
def _graph_find_hotspots(nodes, indegree, adj, fan_in_min=2, fan_out_min=2):
|
|
205
|
+
""" identify potential bottlenecks / hotspots:
|
|
206
|
+
|
|
207
|
+
- high fan-in: nodes that depend on many others
|
|
208
|
+
- high fan-out: nodes that unlock many dependents
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
high_in: [node, ...]
|
|
212
|
+
high_out: [node, ...]
|
|
213
|
+
"""
|
|
214
|
+
high_in = [name for name in nodes if indegree[name] >= fan_in_min]
|
|
215
|
+
high_out = [name for name in nodes if len(adj[name]) >= fan_out_min]
|
|
216
|
+
return high_in, high_out
|
|
217
|
+
|
|
218
|
+
def _graph_format_stats(max_chain_len, chains, high_in, high_out, indegree, adj):
|
|
219
|
+
""" format a Stats: section showing longest chains and fan-in/out hotspots.
|
|
220
|
+
"""
|
|
221
|
+
lines = []
|
|
222
|
+
lines.append('Stats:')
|
|
223
|
+
lines.append(f' Longest chain length (edges): {max_chain_len}')
|
|
224
|
+
|
|
225
|
+
if chains:
|
|
226
|
+
lines.append(' Longest chains:')
|
|
227
|
+
for chain in chains:
|
|
228
|
+
path = ' -> '.join(chain)
|
|
229
|
+
lines.append(f' {path}')
|
|
230
|
+
|
|
231
|
+
if high_in:
|
|
232
|
+
lines.append(' High fan-in nodes (many dependencies):')
|
|
233
|
+
for name in high_in:
|
|
234
|
+
lines.append(f' {name} (indegree={indegree[name]})')
|
|
235
|
+
|
|
236
|
+
if high_out:
|
|
237
|
+
lines.append(' High fan-out nodes (many dependents):')
|
|
238
|
+
for name in high_out:
|
|
239
|
+
lines.append(f' {name} (children={len(adj[name])})')
|
|
240
|
+
|
|
241
|
+
return lines
|
|
242
|
+
|
|
243
|
+
def format_graph_summary(dag):
|
|
244
|
+
""" produce the full human-readable DAG summary used by the CLI.
|
|
245
|
+
|
|
246
|
+
Example output:
|
|
247
|
+
|
|
248
|
+
Graph: 18 nodes, 21 edges
|
|
249
|
+
Roots: [0], [1]
|
|
250
|
+
Leaves: [6], [7]
|
|
251
|
+
Levels: 4
|
|
252
|
+
|
|
253
|
+
Nodes:
|
|
254
|
+
[0] tests/test_a.py::test_A
|
|
255
|
+
...
|
|
256
|
+
|
|
257
|
+
Edges:
|
|
258
|
+
[0] -> [2]
|
|
259
|
+
...
|
|
260
|
+
|
|
261
|
+
Stats:
|
|
262
|
+
Longest chain length (edges): 3
|
|
263
|
+
Longest chains:
|
|
264
|
+
test_a -> test_c -> test_d -> test_f
|
|
265
|
+
High fan-in nodes (many dependencies):
|
|
266
|
+
test_f (indegree=2)
|
|
267
|
+
High fan-out nodes (many dependents):
|
|
268
|
+
test_a (children=2)
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
A single string containing the formatted summary.
|
|
272
|
+
"""
|
|
273
|
+
nodes, ids = _graph_get_nodes_and_ids(dag)
|
|
274
|
+
|
|
275
|
+
if not nodes:
|
|
276
|
+
return 'Graph: 0 nodes, 0 edges'
|
|
277
|
+
|
|
278
|
+
indegree, adj, num_edges = _graph_build_indegree_and_adj(dag, nodes)
|
|
279
|
+
roots, leaves = _graph_find_roots_and_leaves(nodes, indegree, adj)
|
|
280
|
+
levels = _graph_compute_levels(nodes, roots, indegree, adj, ids)
|
|
281
|
+
max_chain_len, chains = _graph_compute_longest_chains(nodes, levels, adj)
|
|
282
|
+
high_in, high_out = _graph_find_hotspots(nodes, indegree, adj)
|
|
283
|
+
|
|
284
|
+
lines = []
|
|
285
|
+
|
|
286
|
+
header_lines = _graph_format_header(
|
|
287
|
+
num_nodes=len(nodes),
|
|
288
|
+
num_edges=num_edges,
|
|
289
|
+
roots=roots,
|
|
290
|
+
leaves=leaves,
|
|
291
|
+
levels_count=len(levels),
|
|
292
|
+
ids=ids,
|
|
293
|
+
)
|
|
294
|
+
lines.extend(header_lines)
|
|
295
|
+
lines.append('')
|
|
296
|
+
|
|
297
|
+
lines.extend(_graph_format_nodes(nodes, ids))
|
|
298
|
+
lines.append('')
|
|
299
|
+
|
|
300
|
+
lines.extend(_graph_format_edges(nodes, adj, ids))
|
|
301
|
+
lines.append('')
|
|
302
|
+
|
|
303
|
+
stats_lines = _graph_format_stats(
|
|
304
|
+
max_chain_len=max_chain_len,
|
|
305
|
+
chains=chains,
|
|
306
|
+
high_in=high_in,
|
|
307
|
+
high_out=high_out,
|
|
308
|
+
indegree=indegree,
|
|
309
|
+
adj=adj,
|
|
310
|
+
)
|
|
311
|
+
lines.extend(stats_lines)
|
|
312
|
+
|
|
313
|
+
return '\n'.join(lines)
|
thread_order/logger.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import logging
|
|
6
|
+
try:
|
|
7
|
+
from colorama import init
|
|
8
|
+
from colorama import Fore, Style
|
|
9
|
+
HAS_COLOR = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
HAS_COLOR = False
|
|
12
|
+
|
|
13
|
+
if HAS_COLOR:
|
|
14
|
+
|
|
15
|
+
class ColoredFormatter(logging.Formatter):
|
|
16
|
+
LEVEL_COLORS = {
|
|
17
|
+
logging.DEBUG: Style.BRIGHT + Fore.CYAN,
|
|
18
|
+
logging.INFO: Style.BRIGHT + Fore.BLUE,
|
|
19
|
+
logging.WARNING: Style.BRIGHT + Fore.YELLOW,
|
|
20
|
+
logging.ERROR: Style.BRIGHT + Fore.RED,
|
|
21
|
+
logging.CRITICAL: Style.BRIGHT + Fore.RED,
|
|
22
|
+
}
|
|
23
|
+
DEFAULT_HIGHLIGHTS = [
|
|
24
|
+
(re.compile(r'\bPASSED\b', re.IGNORECASE), Fore.GREEN),
|
|
25
|
+
(re.compile(r'\bFAILED\b', re.IGNORECASE), Fore.RED),
|
|
26
|
+
(re.compile(r'\bSKIPPED\b', re.IGNORECASE), Fore.YELLOW),
|
|
27
|
+
(re.compile(r'Scheduler::State:\s*(\{.*?^})', re.DOTALL | re.MULTILINE), Fore.MAGENTA)
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def __init__(self, workers, *args, **kwargs):
|
|
31
|
+
self.highlights = kwargs.pop('highlights', []) or self.DEFAULT_HIGHLIGHTS
|
|
32
|
+
self.verbose = kwargs.pop('verbose', False)
|
|
33
|
+
super().__init__(*args, **kwargs)
|
|
34
|
+
|
|
35
|
+
def _apply_highlights(self, message):
|
|
36
|
+
out = message
|
|
37
|
+
for pattern, color in self.highlights:
|
|
38
|
+
def replace(m):
|
|
39
|
+
text = m.group(0)
|
|
40
|
+
return f'{color}{text}{Style.RESET_ALL}{Fore.WHITE}'
|
|
41
|
+
out = pattern.sub(replace, out)
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
def format(self, record):
|
|
45
|
+
timestamp = self.formatTime(record, self.datefmt)
|
|
46
|
+
level_color = self.LEVEL_COLORS.get(record.levelno, Fore.WHITE)
|
|
47
|
+
|
|
48
|
+
raw_msg = record.getMessage()
|
|
49
|
+
colored_msg = self._apply_highlights(raw_msg)
|
|
50
|
+
|
|
51
|
+
# only log thread name if it's not MainThread
|
|
52
|
+
# to avoid cluttering the logs with 'MainThread' entries
|
|
53
|
+
if record.threadName == 'MainThread':
|
|
54
|
+
thread_name = ''
|
|
55
|
+
else:
|
|
56
|
+
thread_name = f'[{record.threadName}] '
|
|
57
|
+
|
|
58
|
+
if self.verbose:
|
|
59
|
+
msg = (
|
|
60
|
+
f"{Style.DIM}{timestamp}{Style.RESET_ALL} "
|
|
61
|
+
f"{level_color}{record.levelname:<5}{Style.RESET_ALL} "
|
|
62
|
+
f"{thread_name}"
|
|
63
|
+
f"{Fore.WHITE}{record.funcName}: {colored_msg}{Style.RESET_ALL}"
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
msg = (
|
|
67
|
+
f"{Style.DIM}{timestamp}{Style.RESET_ALL} "
|
|
68
|
+
f"{thread_name}"
|
|
69
|
+
f"{colored_msg}{Style.RESET_ALL}"
|
|
70
|
+
)
|
|
71
|
+
if record.exc_info:
|
|
72
|
+
msg += '\n' + self.formatException(record.exc_info)
|
|
73
|
+
|
|
74
|
+
return msg
|
|
75
|
+
|
|
76
|
+
class MainThreadAwareFormatter(logging.Formatter):
|
|
77
|
+
|
|
78
|
+
def __init__(self, main_fmt, thread_fmt, workers, thread_prefix='thread_', *args, **kwargs):
|
|
79
|
+
super().__init__(fmt=main_fmt, *args, **kwargs)
|
|
80
|
+
self.main_fmt = main_fmt
|
|
81
|
+
self.thread_fmt = thread_fmt
|
|
82
|
+
self.thread_prefix = thread_prefix
|
|
83
|
+
|
|
84
|
+
def format(self, record):
|
|
85
|
+
if record.threadName == 'MainThread':
|
|
86
|
+
self._style._fmt = self.main_fmt
|
|
87
|
+
else:
|
|
88
|
+
self._style._fmt = self.thread_fmt
|
|
89
|
+
return super().format(record)
|
|
90
|
+
|
|
91
|
+
class ThreadProxyLogger:
|
|
92
|
+
def __getattr__(self, name):
|
|
93
|
+
return getattr(logging.getLogger(threading.current_thread().name), name)
|
|
94
|
+
|
|
95
|
+
def configure_logging(workers, prefix='thread', add_stream_handler=False, highlights=None,
|
|
96
|
+
verbose=False):
|
|
97
|
+
|
|
98
|
+
root_logger = logging.getLogger()
|
|
99
|
+
if getattr(root_logger, '_logging_initialized', False):
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
root_logger.setLevel(logging.DEBUG)
|
|
103
|
+
root_logger.handlers.clear()
|
|
104
|
+
|
|
105
|
+
file_formatter = logging.Formatter(
|
|
106
|
+
'%(asctime)s %(levelname)-5s [%(threadName)s] %(funcName)s: %(message)s')
|
|
107
|
+
|
|
108
|
+
if add_stream_handler or verbose:
|
|
109
|
+
if HAS_COLOR:
|
|
110
|
+
init(autoreset=False)
|
|
111
|
+
stream_formatter = ColoredFormatter(workers, highlights=highlights, verbose=verbose)
|
|
112
|
+
else:
|
|
113
|
+
main_fmt = '%(asctime)s %(message)s'
|
|
114
|
+
thread_fmt = '%(asctime)s [%(threadName)s] %(message)s'
|
|
115
|
+
stream_formatter = MainThreadAwareFormatter(main_fmt, thread_fmt, workers)
|
|
116
|
+
|
|
117
|
+
stream_handler = logging.StreamHandler(sys.stderr)
|
|
118
|
+
stream_handler.setFormatter(stream_formatter)
|
|
119
|
+
stream_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
120
|
+
root_logger.addHandler(stream_handler)
|
|
121
|
+
|
|
122
|
+
root_logger._logging_initialized = True
|
|
123
|
+
|
|
124
|
+
base = os.path.splitext(os.path.basename(sys.argv[0]))[0]
|
|
125
|
+
|
|
126
|
+
def add_handler(name):
|
|
127
|
+
filename = f'{base}_{name}.log'
|
|
128
|
+
logger = logging.getLogger(name)
|
|
129
|
+
if not any(
|
|
130
|
+
isinstance(handler, logging.FileHandler)
|
|
131
|
+
and getattr(handler, 'baseFilename', '').endswith(filename)
|
|
132
|
+
for handler in logger.handlers
|
|
133
|
+
):
|
|
134
|
+
fhandler = logging.FileHandler(filename, mode='a', encoding='utf-8')
|
|
135
|
+
fhandler.setLevel(logging.DEBUG)
|
|
136
|
+
fhandler.setFormatter(file_formatter)
|
|
137
|
+
logger.addHandler(fhandler)
|
|
138
|
+
logger.setLevel(logging.DEBUG)
|
|
139
|
+
return logger
|
|
140
|
+
|
|
141
|
+
add_handler(threading.current_thread().name)
|
|
142
|
+
for index in range(workers):
|
|
143
|
+
add_handler(f'{prefix}_{index}')
|