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.
@@ -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}')