stepup 3.2.1__tar.gz → 3.2.3__tar.gz

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.
Files changed (47) hide show
  1. {stepup-3.2.1/stepup.egg-info → stepup-3.2.3}/PKG-INFO +1 -1
  2. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/browse.py +6 -4
  3. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/cascade.py +3 -0
  4. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/clean.py +4 -3
  5. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/director.py +26 -3
  6. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/file.py +8 -4
  7. stepup-3.2.3/stepup/core/sqlite3.py +83 -0
  8. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/startup.py +1 -1
  9. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/step.py +3 -3
  10. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/tui.py +11 -1
  11. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/utils.py +1 -17
  12. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/worker.py +193 -153
  13. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/workflow.py +8 -6
  14. {stepup-3.2.1 → stepup-3.2.3/stepup.egg-info}/PKG-INFO +1 -1
  15. {stepup-3.2.1 → stepup-3.2.3}/stepup.egg-info/SOURCES.txt +1 -0
  16. {stepup-3.2.1 → stepup-3.2.3}/LICENSE +0 -0
  17. {stepup-3.2.1 → stepup-3.2.3}/MANIFEST.in +0 -0
  18. {stepup-3.2.1 → stepup-3.2.3}/README.md +0 -0
  19. {stepup-3.2.1 → stepup-3.2.3}/pyproject.toml +0 -0
  20. {stepup-3.2.1 → stepup-3.2.3}/setup.cfg +0 -0
  21. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/__init__.py +0 -0
  22. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/__main__.py +0 -0
  23. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/actions.py +0 -0
  24. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/api.py +0 -0
  25. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/asyncio.py +0 -0
  26. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/call.py +0 -0
  27. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/deferred_glob.py +0 -0
  28. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/enums.py +0 -0
  29. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/exceptions.py +0 -0
  30. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/hash.py +0 -0
  31. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/interact.py +0 -0
  32. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/job.py +0 -0
  33. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/logo.svg +0 -0
  34. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/nglob.py +0 -0
  35. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/pytest.py +0 -0
  36. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/render_jinja.py +0 -0
  37. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/reporter.py +0 -0
  38. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/rpc.py +0 -0
  39. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/runner.py +0 -0
  40. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/scheduler.py +0 -0
  41. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/script.py +0 -0
  42. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/stepinfo.py +0 -0
  43. {stepup-3.2.1 → stepup-3.2.3}/stepup/core/watcher.py +0 -0
  44. {stepup-3.2.1 → stepup-3.2.3}/stepup.egg-info/dependency_links.txt +0 -0
  45. {stepup-3.2.1 → stepup-3.2.3}/stepup.egg-info/entry_points.txt +0 -0
  46. {stepup-3.2.1 → stepup-3.2.3}/stepup.egg-info/requires.txt +0 -0
  47. {stepup-3.2.1 → stepup-3.2.3}/stepup.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stepup
3
- Version: 3.2.1
3
+ Version: 3.2.3
4
4
  Summary: StepUp Core provides the basic framework for the StepUp build tool
5
5
  Author-email: Toon Verstraelen <toon.verstraelen@ugent.be>
6
6
  License-Expression: GPL-3.0-or-later
@@ -24,7 +24,6 @@ import contextlib
24
24
  import importlib.resources
25
25
  import os
26
26
  import pickle
27
- import sqlite3
28
27
  import stat
29
28
  import traceback
30
29
  from collections.abc import Iterator
@@ -37,6 +36,7 @@ from path import Path
37
36
 
38
37
  from .enums import FileState, Mandatory, StepState
39
38
  from .hash import fmt_digest
39
+ from .sqlite3 import connect
40
40
 
41
41
 
42
42
  def browse_subcommand(subparser: argparse.ArgumentParser) -> callable:
@@ -275,8 +275,8 @@ class GraphServer(BaseHTTPRequestHandler):
275
275
  print("Loading database...")
276
276
  if self.con is not None:
277
277
  self.con.close()
278
- self.con = sqlite3.Connection(":memory:")
279
- src = sqlite3.Connection(self.path_db)
278
+ self.con = connect(":memory:")
279
+ src = connect(self.path_db)
280
280
  try:
281
281
  src.backup(self.con)
282
282
  finally:
@@ -415,7 +415,9 @@ class GraphServer(BaseHTTPRequestHandler):
415
415
 
416
416
  elif kind == "file":
417
417
  (state_i, digest, mode, mtime, size, inode) = self.con.execute(
418
- "SELECT state, digest, mode, mtime, size, inode FROM file WHERE node = ?", (node_i,)
418
+ "SELECT state, digest, mode, mtime, size, inode AS 'inode [UINT64]' FROM file "
419
+ "WHERE node = ?",
420
+ (node_i,),
419
421
  ).fetchone()
420
422
  state = FileState(state_i)
421
423
  yield f'<p><b>State:</b> <span class="{state.name.lower()}">{state.name}</span></p>'
@@ -562,6 +562,9 @@ class Cascade:
562
562
  # While making this change, the enums were also made more intuitive.
563
563
  # Schema 2 became outdated due to the worker actions.
564
564
  # Schema 3 became outdated due to a change in step table (dirty field).
565
+
566
+ # Delayed Schema updates, for version 5:
567
+ # - Use UINT64 with PARSE_DECLTYPES instead of PARSE_COLNAMES.
565
568
  return 4
566
569
 
567
570
  @classmethod
@@ -29,7 +29,8 @@ from rich.console import Console
29
29
  from .cascade import DROP_CONSUMERS, INITIAL_CONSUMERS, RECURSE_CONSUMERS
30
30
  from .enums import FileState
31
31
  from .hash import FileHash
32
- from .utils import mynormpath, sqlite3_copy_in_memory, translate, translate_back
32
+ from .sqlite3 import copy_db_in_memory
33
+ from .utils import mynormpath, translate, translate_back
33
34
 
34
35
 
35
36
  def clean_subcommand(subparser: argparse.ArgumentParser) -> callable:
@@ -91,7 +92,7 @@ def clean_tool(args: argparse.Namespace):
91
92
  # Copy the database in memory and work on the copy.
92
93
  root = Path(os.getenv("STEPUP_ROOT", "."))
93
94
  path_db = root / ".stepup/graph.db"
94
- with sqlite3_copy_in_memory(path_db) as con:
95
+ with copy_db_in_memory(path_db) as con:
95
96
  clean(con, tr_paths, args)
96
97
 
97
98
 
@@ -175,7 +176,7 @@ def fmtnum(i: int):
175
176
  CREATE_INITIAL_PATHS = "CREATE TABLE temp.initial_path(path TEXT PRIMARY KEY) WITHOUT ROWID"
176
177
 
177
178
  SELECT_OUTPUTS = f"""
178
- SELECT label, file.state, orphan, digest, mode, mtime, size, inode FROM node
179
+ SELECT label, file.state, orphan, digest, mode, mtime, size, inode AS 'inode [UINT64]' FROM node
179
180
  JOIN all_consumer ON node.i = all_consumer.current
180
181
  JOIN file ON file.node = all_consumer.current
181
182
  WHERE file.state in
@@ -24,7 +24,6 @@ import asyncio
24
24
  import logging
25
25
  import os
26
26
  import signal
27
- import sqlite3
28
27
  import sys
29
28
  import time
30
29
  import traceback
@@ -34,7 +33,10 @@ from importlib.metadata import version as get_version
34
33
  import attrs
35
34
  from path import Path
36
35
 
37
- from stepup.core.step import Step
36
+ try:
37
+ import yappi
38
+ except ImportError:
39
+ yappi = None
38
40
 
39
41
  from .asyncio import wait_for_events
40
42
  from .enums import ReturnCode, StepState
@@ -45,7 +47,9 @@ from .reporter import ReporterClient
45
47
  from .rpc import allow_rpc, serve_socket_rpc
46
48
  from .runner import Runner
47
49
  from .scheduler import Scheduler
50
+ from .sqlite3 import connect
48
51
  from .startup import startup_from_db
52
+ from .step import Step
49
53
  from .stepinfo import StepInfo
50
54
  from .utils import DBLock, check_plan, mynormpath
51
55
  from .watcher import WATCHER_AVAILABLE, Watcher
@@ -71,6 +75,15 @@ async def async_main():
71
75
  print(f"SOCKET {args.director_socket}", file=sys.stderr)
72
76
  print(f"PID {os.getpid()}", file=sys.stderr)
73
77
  print(f"LOG_LEVEL {args.log_level}", file=sys.stderr)
78
+ if args.yappi:
79
+ if yappi is None:
80
+ print(
81
+ "Yappi profiling requested, but the yappi module is not installed.",
82
+ file=sys.stderr,
83
+ )
84
+ else:
85
+ yappi.set_clock_type("cpu")
86
+ yappi.start(builtins=True, profile_threads=True)
74
87
  async with ReporterClient.socket(args.reporter_socket) as reporter:
75
88
  num_workers = interpret_num_workers(args.num_workers)
76
89
  await reporter.set_num_workers(num_workers)
@@ -95,6 +108,10 @@ async def async_main():
95
108
  finally:
96
109
  await reporter("DIRECTOR", "See you!")
97
110
  await reporter.shutdown()
111
+ if args.yappi and yappi is not None:
112
+ yappi.stop()
113
+ stats = yappi.get_func_stats()
114
+ stats.save(".stepup/director.prof", type="pstat")
98
115
  sys.exit(returncode.value)
99
116
 
100
117
 
@@ -170,6 +187,12 @@ def parse_args() -> argparse.Namespace:
170
187
  action=argparse.BooleanOptionalAction,
171
188
  help="Do not remove outdated output files.",
172
189
  )
190
+ parser.add_argument(
191
+ "--yappi",
192
+ default=False,
193
+ action=argparse.BooleanOptionalAction,
194
+ help="Profile the director with Yappi (must be installed).",
195
+ )
173
196
  args = parser.parse_args()
174
197
  if WATCHER_AVAILABLE:
175
198
  if args.watch_first:
@@ -235,7 +258,7 @@ async def serve(
235
258
  check_plan("plan.py")
236
259
 
237
260
  # Create basic components
238
- con = sqlite3.connect(".stepup/graph.db", cached_statements=1024)
261
+ con = connect(".stepup/graph.db")
239
262
  dblock = DBLock(con)
240
263
  workflow = Workflow(con)
241
264
  scheduler = Scheduler(workflow.job_queue, workflow.config_queue, workflow.job_queue_changed)
@@ -29,6 +29,7 @@ from path import Path
29
29
  from .cascade import Node
30
30
  from .enums import DirWatch, FileState
31
31
  from .hash import FileHash
32
+ from .sqlite3 import UInt64
32
33
  from .utils import format_digest
33
34
 
34
35
  if TYPE_CHECKING:
@@ -102,7 +103,10 @@ class File(Node):
102
103
  # If the file was previously BUILT or OUTDATED, and created again as AWAITED,
103
104
  # it should copy that state
104
105
  if state == FileState.AWAITED:
105
- sql = "SELECT state, digest, mode, mtime, size, inode FROM file WHERE node = ?"
106
+ sql = (
107
+ "SELECT state, digest, mode, mtime, size, inode AS 'inode [UINT64]' FROM file "
108
+ "WHERE node = ?"
109
+ )
106
110
  row = self.con.execute(sql, (self.i,)).fetchone()
107
111
  if row is not None and row[0] in (FileState.BUILT.value, FileState.OUTDATED.value):
108
112
  state = FileState(row[0])
@@ -121,7 +125,7 @@ class File(Node):
121
125
  "mode": mode,
122
126
  "mtime": mtime,
123
127
  "size": size,
124
- "inode": inode,
128
+ "inode": UInt64(inode),
125
129
  },
126
130
  )
127
131
  # If the state is BUILT, mark it as OUTDATED to force a rebuild.
@@ -215,7 +219,7 @@ class File(Node):
215
219
  self.con.execute(sql, (state.value, self.i))
216
220
 
217
221
  def get_hash(self) -> FileHash:
218
- sql = "SELECT digest, mode, mtime, size, inode FROM file WHERE node = ?"
222
+ sql = "SELECT digest, mode, mtime, size, inode AS 'inode [UINT64]' FROM file WHERE node = ?"
219
223
  row = self.con.execute(sql, (self.i,)).fetchone()
220
224
  return FileHash(*row)
221
225
 
@@ -299,7 +303,7 @@ class File(Node):
299
303
  # Local import to avoid cyclic imports.
300
304
  from .step import Step # noqa: PLC0415
301
305
 
302
- for step in self.consumers(Step):
306
+ for step in self.consumers(Step, include_orphans=True):
303
307
  step.mark_pending()
304
308
  elif state != FileState.OUTDATED:
305
309
  raise ValueError(f"Cannot make file oudated when its state is {state}")
@@ -0,0 +1,83 @@
1
+ # StepUp Core provides the basic framework for the StepUp build tool.
2
+ # Copyright 2024-2026 Toon Verstraelen
3
+ #
4
+ # This file is part of StepUp Core.
5
+ #
6
+ # StepUp Core is free software; you can redistribute it and/or
7
+ # modify it under the terms of the GNU General Public License
8
+ # as published by the Free Software Foundation; either version 3
9
+ # of the License, or (at your option) any later version.
10
+ #
11
+ # StepUp Core is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program; if not, see <http://www.gnu.org/licenses/>
18
+ #
19
+ # --
20
+ """Wrapper for SQLite3 functionality."""
21
+
22
+ import contextlib
23
+ import os
24
+ import sqlite3
25
+ from collections.abc import Iterator
26
+ from typing import Self
27
+
28
+ __all__ = ("UInt64", "connect", "copy_db_in_memory")
29
+
30
+
31
+ class UInt64(int):
32
+ """A wrapper to tell SQLite this int should be treated as an unsigned 64-bit value."""
33
+
34
+ MAX_SIGNED_64 = 2**63 - 1
35
+ MAX_WRAPAROUND_64 = 2**64
36
+ MAX_UNSIGNED_64 = MAX_WRAPAROUND_64 - 1
37
+
38
+ @staticmethod
39
+ def adapt(val: Self) -> int:
40
+ if not (0 <= val <= UInt64.MAX_UNSIGNED_64):
41
+ raise ValueError(f"Value {val} out of UINT64 range")
42
+ return val - UInt64.MAX_WRAPAROUND_64 if val > UInt64.MAX_SIGNED_64 else val
43
+
44
+ @staticmethod
45
+ def convert(val: bytes) -> Self:
46
+ val = int(val)
47
+ if val < 0:
48
+ val += UInt64.MAX_WRAPAROUND_64
49
+ return UInt64(val)
50
+
51
+
52
+ sqlite3.register_adapter(UInt64, UInt64.adapt)
53
+ sqlite3.register_converter("UINT64", UInt64.convert)
54
+
55
+
56
+ def connect(path: str | os.PathLike[str], **kwargs) -> sqlite3.Connection:
57
+ """Connect to a SQLite database, with the appropriate settings for StepUp.
58
+
59
+ The following deviations from the default settings are used:
60
+
61
+ - Types can be detected from column names,
62
+ which allows us to use the custom UINT64 type for file inodes.
63
+ - The `cached_statements` parameter is set to a large value to improve
64
+ performance when executing many similar statements.
65
+ """
66
+ my_kwargs = {"cached_statements": 1024, "detect_types": sqlite3.PARSE_COLNAMES}
67
+ my_kwargs.update(kwargs)
68
+ return sqlite3.connect(path, **my_kwargs)
69
+
70
+
71
+ @contextlib.contextmanager
72
+ def copy_db_in_memory(path_db) -> Iterator[sqlite3.Connection]:
73
+ """Copy an SQLite database into memory and yield the connection."""
74
+ dst = connect(":memory:")
75
+ try:
76
+ src = connect(path_db)
77
+ try:
78
+ src.backup(dst)
79
+ finally:
80
+ src.close()
81
+ yield dst
82
+ finally:
83
+ dst.close()
@@ -124,7 +124,7 @@ async def scan_file_changes(
124
124
  ) -> tuple[set[str], set[str]]:
125
125
  """Check all files in the workflow for changes."""
126
126
  sql = (
127
- "SELECT label, state, digest, mode, mtime, size, inode "
127
+ "SELECT label, state, digest, mode, mtime, size, inode AS 'inode [UINT64]' "
128
128
  "FROM node JOIN file ON node.i = file.node AND state NOT IN (?, ?) AND NOT orphan"
129
129
  )
130
130
  data = (FileState.AWAITED.value, FileState.VOLATILE.value)
@@ -558,7 +558,7 @@ class Step(Node):
558
558
  fields.append("state")
559
559
  join_file = True
560
560
  if yield_hash:
561
- fields.extend(["digest", "mode", "mtime", "size", "inode"])
561
+ fields.extend(["digest", "mode", "mtime", "size", "inode AS 'inode [UINT64]'"])
562
562
  join_file = True
563
563
  if yield_orphan:
564
564
  fields.append("orphan")
@@ -731,7 +731,7 @@ class Step(Node):
731
731
  sql = (
732
732
  "SELECT node.label, node.orphan, file.state, "
733
733
  "EXISTS (SELECT 1 FROM amended_dep WHERE amended_dep.i = dep.i), "
734
- "file.digest, file.mode, file.mtime, file.size, file.inode "
734
+ "file.digest, file.mode, file.mtime, file.size, file.inode AS 'inode [UINT64]' "
735
735
  "FROM node JOIN dependency AS dep ON node.i = dep.supplier "
736
736
  "JOIN file ON file.node = node.i "
737
737
  "WHERE dep.consumer = ?"
@@ -987,6 +987,6 @@ class Step(Node):
987
987
  logger.info("Mark %s step PENDING: %s", state.name, self.label)
988
988
  self.set_state(StepState.PENDING)
989
989
  # First make all consumers (output files) pending
990
- for file in self.consumers(File):
990
+ for file in self.consumers(File, include_orphans=True):
991
991
  if file.get_state() == FileState.BUILT:
992
992
  file.mark_outdated()
@@ -123,6 +123,8 @@ async def async_boot(args: argparse.Namespace):
123
123
  argv.append("--watch-first")
124
124
  if not args.clean:
125
125
  argv.append("--no-clean")
126
+ if args.yappi:
127
+ argv.append("--yappi")
126
128
  returncode = 1 # Internal error unless it is overriden later by the director subprocess
127
129
  try:
128
130
  with open(".stepup/director.log", "w") as log_file:
@@ -299,7 +301,15 @@ def boot_subcommand(subparsers) -> callable:
299
301
  default=os.getenv("STEPUP_PERF", None),
300
302
  nargs="?",
301
303
  const="500",
302
- help="Run the director under perf record, by default at a frequency of 500 Hz. "
304
+ help="Profile the director with perf, by default at a frequency of %(const)s Hz. "
303
305
  "(Only supported on Linux with perf installed.)",
304
306
  )
307
+ parser.add_argument(
308
+ "--yappi",
309
+ default=string_to_bool(os.getenv("STEPUP_YAPPI", "0")),
310
+ action=argparse.BooleanOptionalAction,
311
+ help="Profile the director with Yappi (must be installed). "
312
+ "This produces a .stepup/director.prof file that can be analyzed with "
313
+ "tools like SnakeViz.",
314
+ )
305
315
  return boot_tool
@@ -20,7 +20,6 @@
20
20
  """Small utilities used throughout."""
21
21
 
22
22
  import asyncio
23
- import contextlib
24
23
  import logging
25
24
  import os
26
25
  import re
@@ -28,7 +27,7 @@ import shlex
28
27
  import sqlite3
29
28
  import string
30
29
  import sys
31
- from collections.abc import Collection, Iterator
30
+ from collections.abc import Collection
32
31
 
33
32
  import attrs
34
33
  from path import Path
@@ -429,18 +428,3 @@ def string_to_bool(v: str | bool) -> bool:
429
428
  return False
430
429
  raise ValueError(f"Cannot interpret '{v}' as a boolean value.")
431
430
  raise TypeError(f"Expected a boolean value or string. Got {type(v).__name__}")
432
-
433
-
434
- @contextlib.contextmanager
435
- def sqlite3_copy_in_memory(path_db) -> Iterator[sqlite3.Connection]:
436
- """Copy an SQLite database into memory and yield the connection."""
437
- dst = sqlite3.Connection(":memory:")
438
- try:
439
- src = sqlite3.Connection(path_db)
440
- try:
441
- src.backup(dst)
442
- finally:
443
- src.close()
444
- yield dst
445
- finally:
446
- dst.close()
@@ -25,6 +25,7 @@ import contextlib
25
25
  import ctypes
26
26
  import inspect
27
27
  import io
28
+ import json
28
29
  import logging
29
30
  import os
30
31
  import queue
@@ -228,8 +229,6 @@ class WorkerClient:
228
229
  return True
229
230
 
230
231
  # Delegate the calculation of the output part of the step hash to the worker.
231
- # With skipping=True, the worker knows the outputs should not have changed
232
- # and will report it on screen.
233
232
  new_step_hash, new_out_hashes = await self.compute_out_step_hash(step, new_step_hash)
234
233
 
235
234
  if step_hash.out_digest != new_step_hash.out_digest:
@@ -707,6 +706,38 @@ def check_executable(executable: Path, shebang: str | None = None) -> bool:
707
706
  return True
708
707
 
709
708
 
709
+ @attrs.define
710
+ class Timer:
711
+ """Context managers to construct a JSON string with times spent in typical worker tasks."""
712
+
713
+ start: float = attrs.field(init=False, factory=perf_counter)
714
+ sections: dict[str, float] = attrs.field(init=False, factory=dict)
715
+
716
+ @contextlib.contextmanager
717
+ def section(self, section: str):
718
+ """Context manager for a timed section.
719
+
720
+ Parameters
721
+ ----------
722
+ section
723
+ The name of the section.
724
+ """
725
+ time = perf_counter()
726
+ try:
727
+ yield
728
+ finally:
729
+ time = perf_counter() - time
730
+ self.sections[section] = self.sections.get(section, 0.0) + time
731
+
732
+ def report(self):
733
+ """Write the timings line to standard error."""
734
+ time = perf_counter() - self.start
735
+ sections = self.sections.copy()
736
+ sections["idle/other"] = time - sum(sections.values())
737
+ sections["total"] = time
738
+ print(f"TIMINGS: {json.dumps(sections)}", file=sys.stderr)
739
+
740
+
710
741
  @attrs.define
711
742
  class WorkerHandler:
712
743
  """RPC Handler in the worker process to respond to requests from the WorkerClient."""
@@ -716,6 +747,7 @@ class WorkerHandler:
716
747
  explain_rerun: bool = attrs.field()
717
748
  stop_event: asyncio.Event = attrs.field(factory=asyncio.Event)
718
749
  step: WorkerStep | None = attrs.field(init=False, default=None)
750
+ timer: Timer = attrs.field(init=False, factory=Timer)
719
751
 
720
752
  @allow_rpc
721
753
  def shutdown(self):
@@ -777,18 +809,19 @@ class WorkerHandler:
777
809
  The new hash of the step, with the input part already computed, if available.
778
810
  `None` is yielded if, unexpectedly, some inputs are missing or have changed.
779
811
  """
780
- if self.step is not None:
781
- raise RPCError(
782
- "Worker cannot initiate two steps at the same time. "
783
- f"Still working on {self.step.action}"
784
- )
812
+ with self.timer.section("new_step"):
813
+ if self.step is not None:
814
+ raise RPCError(
815
+ "Worker cannot initiate two steps at the same time. "
816
+ f"Still working on {self.step.action}"
817
+ )
785
818
 
786
- # Create the step
787
- action, workdir = split_step_label(label)
788
- self.step = WorkerStep(i, action, workdir)
819
+ # Create the step
820
+ action, workdir = split_step_label(label)
821
+ self.step = WorkerStep(i, action, workdir)
789
822
 
790
- # Create initial StepHash
791
- return self.compute_inp_step_hash(inp_hashes, env_vars, check_hash)[0]
823
+ # Create initial StepHash
824
+ return self.compute_inp_step_hash(inp_hashes, env_vars, check_hash)[0]
792
825
 
793
826
  def compute_inp_step_hash(
794
827
  self,
@@ -819,41 +852,44 @@ class WorkerHandler:
819
852
  so they can be updated in StepUp's workflow database if desired.
820
853
  Unchanged ones are not included.
821
854
  """
822
- # Check the input hashes
823
- messages = []
824
- new_inp_hashes = []
825
- all_inp_hashes = []
826
- for path, old_file_hash in old_inp_hashes:
827
- new_file_hash = old_file_hash.regen(path)
828
- all_inp_hashes.append((path, new_file_hash))
829
- if check_hash and new_file_hash != old_file_hash:
830
- if new_file_hash.is_unknown:
831
- messages.append(f"Input vanished unexpectedly: {path} ")
832
- else:
833
- messages.append(
834
- f"Input changed unexpectedly: {path} "
835
- + fmt_file_hash_diff(old_file_hash, new_file_hash)
836
- )
837
- new_inp_hashes.append((path, new_file_hash))
838
-
839
- # If there are unexpected issues with inputs, bail out.
840
- if len(messages) > 0:
841
- self.step.inp_messages = messages
842
- self.step.success = False
843
- return None, new_inp_hashes
855
+ with self.timer.section("compute_inp_step_hash"):
856
+ # Check the input hashes
857
+ messages = []
858
+ new_inp_hashes = []
859
+ all_inp_hashes = []
860
+ for path, old_file_hash in old_inp_hashes:
861
+ new_file_hash = old_file_hash.regen(path)
862
+ all_inp_hashes.append((path, new_file_hash))
863
+ if check_hash and new_file_hash != old_file_hash:
864
+ if new_file_hash.is_unknown:
865
+ messages.append(f"Input vanished unexpectedly: {path} ")
866
+ else:
867
+ messages.append(
868
+ f"Input changed unexpectedly: {path} "
869
+ + fmt_file_hash_diff(old_file_hash, new_file_hash)
870
+ )
871
+ new_inp_hashes.append((path, new_file_hash))
872
+
873
+ # If there are unexpected issues with inputs, bail out.
874
+ if len(messages) > 0:
875
+ self.step.inp_messages = messages
876
+ self.step.success = False
877
+ return None, new_inp_hashes
844
878
 
845
- # Add the environment variables to the hash
846
- env_var_values = [(env_var, os.environ.get(env_var)) for env_var in env_vars]
879
+ # Add the environment variables to the hash
880
+ env_var_values = [(env_var, os.environ.get(env_var)) for env_var in env_vars]
847
881
 
848
- # Create the StepHash
849
- label = self.step.action
850
- if self.step.workdir != "./":
851
- label += f" # wd={self.step.workdir}"
852
- result = StepHash.from_inp(f"{label}", self.explain_rerun, all_inp_hashes, env_var_values)
882
+ # Create the StepHash
883
+ label = self.step.action
884
+ if self.step.workdir != "./":
885
+ label += f" # wd={self.step.workdir}"
886
+ result = StepHash.from_inp(
887
+ f"{label}", self.explain_rerun, all_inp_hashes, env_var_values
888
+ )
853
889
 
854
- # Copy the inp_digest, because it can be useful for some actions.
855
- self.step.inp_digest = result.inp_digest
856
- return result, []
890
+ # Copy the inp_digest, because it can be useful for some actions.
891
+ self.step.inp_digest = result.inp_digest
892
+ return result, []
857
893
 
858
894
  @allow_rpc
859
895
  def compute_out_step_hash(
@@ -881,22 +917,23 @@ class WorkerHandler:
881
917
  so they can be updated in StepUp's workflow database if desired.
882
918
  Unchanged ones are not included.
883
919
  """
884
- # Check the output hashes
885
- new_out_hashes = []
886
- all_out_hashes = []
887
- for path, old_file_hash in sorted(old_out_hashes):
888
- new_file_hash = old_file_hash.regen(path)
889
- all_out_hashes.append((path, new_file_hash))
890
- if new_file_hash != old_file_hash:
891
- new_out_hashes.append((path, new_file_hash))
892
- if new_file_hash.is_unknown:
893
- self.step.out_missing.append(path)
894
- self.step.success = False
920
+ with self.timer.section("compute_out_step_hash"):
921
+ # Check the output hashes
922
+ new_out_hashes = []
923
+ all_out_hashes = []
924
+ for path, old_file_hash in sorted(old_out_hashes):
925
+ new_file_hash = old_file_hash.regen(path)
926
+ all_out_hashes.append((path, new_file_hash))
927
+ if new_file_hash != old_file_hash:
928
+ new_out_hashes.append((path, new_file_hash))
929
+ if new_file_hash.is_unknown:
930
+ self.step.out_missing.append(path)
931
+ self.step.success = False
895
932
 
896
- # Update the step hash
897
- if step_hash is not None:
898
- step_hash = step_hash.evolve_out(all_out_hashes)
899
- return step_hash, new_out_hashes
933
+ # Update the step hash
934
+ if step_hash is not None:
935
+ step_hash = step_hash.evolve_out(all_out_hashes)
936
+ return step_hash, new_out_hashes
900
937
 
901
938
  @allow_rpc
902
939
  def compute_full_step_hash(
@@ -956,57 +993,58 @@ class WorkerHandler:
956
993
 
957
994
  @allow_rpc
958
995
  async def run(self):
959
- await self.reporter("START", self.step.description)
960
- await self.reporter.start_step(self.step.description, self.step.i)
961
-
962
- # For internal use in actions:
963
- os.environ["STEPUP_STEP_I"] = str(self.step.i)
964
- # Client code may use the following:
965
- os.environ["STEPUP_STEP_INP_DIGEST"] = self.step.inp_digest.hex()
966
- os.environ["ROOT"] = str(Path.cwd().relpath(self.step.workdir))
967
- os.environ["HERE"] = str(self.step.workdir.relpath())
968
- # Note: the variables defined here should be listed in stepup.core.api.getenv
969
-
970
- # Create IO redirection for stdout and stderr
971
- step_err = io.StringIO()
972
- step_out = io.StringIO()
973
- with (
974
- contextlib.chdir(self.step.workdir),
975
- contextlib.redirect_stderr(step_err),
976
- contextlib.redirect_stdout(step_out),
977
- ):
996
+ with self.timer.section("run"):
997
+ await self.reporter("START", self.step.description)
998
+ await self.reporter.start_step(self.step.description, self.step.i)
999
+
1000
+ # For internal use in actions:
1001
+ os.environ["STEPUP_STEP_I"] = str(self.step.i)
1002
+ # Client code may use the following:
1003
+ os.environ["STEPUP_STEP_INP_DIGEST"] = self.step.inp_digest.hex()
1004
+ os.environ["ROOT"] = str(Path.cwd().relpath(self.step.workdir))
1005
+ os.environ["HERE"] = str(self.step.workdir.relpath())
1006
+ # Note: the variables defined here should be listed in stepup.core.api.getenv
1007
+
1008
+ # Create IO redirection for stdout and stderr
1009
+ step_err = io.StringIO()
1010
+ step_out = io.StringIO()
1011
+ with (
1012
+ contextlib.chdir(self.step.workdir),
1013
+ contextlib.redirect_stderr(step_err),
1014
+ contextlib.redirect_stdout(step_out),
1015
+ ):
1016
+ if self.show_perf:
1017
+ ru_initial = resource.getrusage(resource.RUSAGE_CHILDREN)
1018
+ pt_initial = perf_counter()
1019
+ self.step.thread = WorkThread(self.step.action)
1020
+ self.step.thread.start()
1021
+ await self.step.thread.done.wait()
1022
+ self.step.thread.join()
1023
+ self.step.returncode = self.step.thread.returncode
1024
+ self.step.thread = None
1025
+ self.step.stdout = step_out.getvalue()
1026
+ self.step.stderr = step_err.getvalue()
1027
+
1028
+ # Clean up environment variables (to avoid potential confusion)
1029
+ del os.environ["STEPUP_STEP_I"]
1030
+ del os.environ["STEPUP_STEP_INP_DIGEST"]
1031
+ del os.environ["ROOT"]
1032
+ del os.environ["HERE"]
1033
+
1034
+ # Process results of the step.
978
1035
  if self.show_perf:
979
- ru_initial = resource.getrusage(resource.RUSAGE_CHILDREN)
980
- pt_initial = perf_counter()
981
- self.step.thread = WorkThread(self.step.action)
982
- self.step.thread.start()
983
- await self.step.thread.done.wait()
984
- self.step.thread.join()
985
- self.step.returncode = self.step.thread.returncode
986
- self.step.thread = None
987
- self.step.stdout = step_out.getvalue()
988
- self.step.stderr = step_err.getvalue()
989
-
990
- # Clean up environment variables (to avoid potential confusion)
991
- del os.environ["STEPUP_STEP_I"]
992
- del os.environ["STEPUP_STEP_INP_DIGEST"]
993
- del os.environ["ROOT"]
994
- del os.environ["HERE"]
995
-
996
- # Process results of the step.
997
- if self.show_perf:
998
- ru_final = resource.getrusage(resource.RUSAGE_CHILDREN)
999
- utime = ru_final.ru_utime - ru_initial.ru_utime
1000
- stime = ru_final.ru_stime - ru_initial.ru_stime
1001
- wtime = perf_counter() - pt_initial
1002
- ru_lines = [
1003
- f"User CPU time [s]: {utime:9.4f}",
1004
- f"System CPU time [s]: {stime:9.4f}",
1005
- f"Total CPU time [s]: {utime + stime:9.4f}",
1006
- f"Wall time [s]: {wtime:9.4f}",
1007
- ]
1008
- self.step.perf_info = "\n".join(ru_lines)
1009
- self.step.success = self.step.returncode == 0
1036
+ ru_final = resource.getrusage(resource.RUSAGE_CHILDREN)
1037
+ utime = ru_final.ru_utime - ru_initial.ru_utime
1038
+ stime = ru_final.ru_stime - ru_initial.ru_stime
1039
+ wtime = perf_counter() - pt_initial
1040
+ ru_lines = [
1041
+ f"User CPU time [s]: {utime:9.4f}",
1042
+ f"System CPU time [s]: {stime:9.4f}",
1043
+ f"Total CPU time [s]: {utime + stime:9.4f}",
1044
+ f"Wall time [s]: {wtime:9.4f}",
1045
+ ]
1046
+ self.step.perf_info = "\n".join(ru_lines)
1047
+ self.step.success = self.step.returncode == 0
1010
1048
 
1011
1049
  @allow_rpc
1012
1050
  def get_success(self) -> bool:
@@ -1021,51 +1059,52 @@ class WorkerHandler:
1021
1059
 
1022
1060
  @allow_rpc
1023
1061
  async def report(self):
1024
- pages = []
1025
- if not self.step.success:
1026
- # Format command such that it can be copied and pasted into a shell.
1027
- command = "stepup act "
1028
- if any(word.startswith("-") for word in shlex.split(self.step.action)):
1029
- command += "-- "
1030
- command += self.step.action
1031
- if self.step.workdir != "./":
1032
- command = f"(cd {self.step.workdir} && {command})"
1033
- lines = [f"Command {command}"]
1034
- # Other info on the execution of the step
1035
- if self.step.returncode is not None:
1036
- lines.append(f"Return code {self.step.returncode}")
1037
- pages.append(("Step info", "\n".join(lines)))
1038
- if len(self.step.perf_info) > 0:
1039
- pages.append(("Performance details", self.step.perf_info))
1040
- if self.step.rescheduled_info != "":
1041
- pages.append(
1042
- (
1043
- "Rescheduling due to unavailable amended inputs",
1044
- self.step.rescheduled_info,
1062
+ with self.timer.section("report"):
1063
+ pages = []
1064
+ if not self.step.success:
1065
+ # Format command such that it can be copied and pasted into a shell.
1066
+ command = "stepup act "
1067
+ if any(word.startswith("-") for word in shlex.split(self.step.action)):
1068
+ command += "-- "
1069
+ command += self.step.action
1070
+ if self.step.workdir != "./":
1071
+ command = f"(cd {self.step.workdir} && {command})"
1072
+ lines = [f"Command {command}"]
1073
+ # Other info on the execution of the step
1074
+ if self.step.returncode is not None:
1075
+ lines.append(f"Return code {self.step.returncode}")
1076
+ pages.append(("Step info", "\n".join(lines)))
1077
+ if len(self.step.perf_info) > 0:
1078
+ pages.append(("Performance details", self.step.perf_info))
1079
+ if self.step.rescheduled_info != "":
1080
+ pages.append(
1081
+ (
1082
+ "Rescheduling due to unavailable amended inputs",
1083
+ self.step.rescheduled_info,
1084
+ )
1045
1085
  )
1046
- )
1047
- else:
1048
- if len(self.step.inp_messages) > 0:
1049
- self.step.inp_messages.sort()
1050
- pages.append(("Invalid inputs", "\n".join(self.step.inp_messages)))
1051
- if len(self.step.out_missing) > 0:
1052
- self.step.out_missing.sort()
1053
- pages.append(("Expected outputs not created", "\n".join(self.step.out_missing)))
1054
- stdout = self.step.stdout.rstrip()
1055
- if len(stdout) > 0:
1056
- pages.append(("Standard output", stdout))
1057
- stderr = self.step.stderr.rstrip()
1058
- if len(stderr) > 0:
1059
- pages.append(("Standard error", stderr))
1060
- if self.step.rescheduled_info != "":
1061
- action = "RESCHEDULE"
1062
- elif self.step.success:
1063
- action = "SUCCESS"
1064
- else:
1065
- action = "FAIL"
1066
- await self.reporter.stop_step(self.step.i)
1067
- await self.reporter(action, self.step.description, pages)
1068
- self.step = None
1086
+ else:
1087
+ if len(self.step.inp_messages) > 0:
1088
+ self.step.inp_messages.sort()
1089
+ pages.append(("Invalid inputs", "\n".join(self.step.inp_messages)))
1090
+ if len(self.step.out_missing) > 0:
1091
+ self.step.out_missing.sort()
1092
+ pages.append(("Expected outputs not created", "\n".join(self.step.out_missing)))
1093
+ stdout = self.step.stdout.rstrip()
1094
+ if len(stdout) > 0:
1095
+ pages.append(("Standard output", stdout))
1096
+ stderr = self.step.stderr.rstrip()
1097
+ if len(stderr) > 0:
1098
+ pages.append(("Standard error", stderr))
1099
+ if self.step.rescheduled_info != "":
1100
+ action = "RESCHEDULE"
1101
+ elif self.step.success:
1102
+ action = "SUCCESS"
1103
+ else:
1104
+ action = "FAIL"
1105
+ await self.reporter.stop_step(self.step.i)
1106
+ await self.reporter(action, self.step.description, pages)
1107
+ self.step = None
1069
1108
 
1070
1109
  @allow_rpc
1071
1110
  async def skip(self, step_hash: StepHash):
@@ -1155,6 +1194,7 @@ async def async_main():
1155
1194
  # Create the worker handler for the RPC server.
1156
1195
  handler = WorkerHandler(reporter, args.show_perf, args.explain_rerun)
1157
1196
  await serve_socket_rpc(handler, args.worker_socket, handler.stop_event)
1197
+ handler.timer.report()
1158
1198
 
1159
1199
 
1160
1200
  def main():
@@ -39,6 +39,7 @@ from .exceptions import GraphError
39
39
  from .file import File
40
40
  from .hash import FileHash, fmt_digest
41
41
  from .nglob import NGlobMulti, convert_nglob_to_regex, iter_wildcard_names
42
+ from .sqlite3 import UInt64
42
43
  from .step import Step
43
44
  from .utils import myparent, string_to_bool
44
45
 
@@ -568,13 +569,14 @@ class Workflow(Cascade):
568
569
  sql = "INSERT INTO temp.missing VALUES (?)"
569
570
  self.con.executemany(sql, ((file.i,) for file in deferred))
570
571
  sql = (
571
- "SELECT label, digest, mtime, mode, size, inode FROM temp.missing "
572
+ "SELECT label, digest, mode, mtime, size, inode AS 'inode [UINT64]' "
573
+ "FROM temp.missing "
572
574
  "JOIN node ON node.i = temp.missing.node "
573
575
  "JOIN file ON file.node = temp.missing.node"
574
576
  )
575
577
  return [
576
- (path, FileHash(digest, mtime, mode, size, inode))
577
- for path, digest, mtime, mode, size, inode in self.con.execute(sql)
578
+ (path, FileHash(digest, mode, mtime, size, inode))
579
+ for path, digest, mode, mtime, size, inode in self.con.execute(sql)
578
580
  ]
579
581
  finally:
580
582
  self.con.execute("DROP TABLE IF EXISTS temp.missing")
@@ -597,7 +599,7 @@ class Workflow(Cascade):
597
599
  self.con.execute("CREATE TABLE temp.paths(path TEXT PRIMARY KEY)")
598
600
  self.con.executemany("INSERT INTO temp.paths VALUES (?)", ((path,) for path in paths))
599
601
  sql = (
600
- "SELECT label, digest, mode, mtime, size, inode FROM node "
602
+ "SELECT label, digest, mode, mtime, size, inode AS 'inode [UINT64]' FROM node "
601
603
  "JOIN file ON file.node = node.i JOIN temp.paths ON label = temp.paths.path"
602
604
  )
603
605
  return [
@@ -735,7 +737,7 @@ class Workflow(Cascade):
735
737
  "UPDATE file SET state = ?, digest = ?, mode = ?, mtime = ?, size = ?, inode = ? "
736
738
  "WHERE node = ?",
737
739
  (
738
- (state.value, fh.digest, fh.mode, fh.mtime, fh.size, fh.inode, i)
740
+ (state.value, fh.digest, fh.mode, fh.mtime, fh.size, UInt64(fh.inode), i)
739
741
  for i, state, fh in new_states_hashes
740
742
  ),
741
743
  )
@@ -1141,7 +1143,7 @@ class Workflow(Cascade):
1141
1143
  file.orphan()
1142
1144
  # Delete outputs of steps that are no longer mandatory.
1143
1145
  cur = self.con.execute(
1144
- "SELECT label, digest, mode, mtime, size, inode FROM file "
1146
+ "SELECT label, digest, mode, mtime, size, inode AS 'inode [UINT64]' FROM file "
1145
1147
  "JOIN node ON node.i = file.node "
1146
1148
  "JOIN dependency ON node.i = consumer "
1147
1149
  "JOIN step ON step.node = supplier "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stepup
3
- Version: 3.2.1
3
+ Version: 3.2.3
4
4
  Summary: StepUp Core provides the basic framework for the StepUp build tool
5
5
  Author-email: Toon Verstraelen <toon.verstraelen@ugent.be>
6
6
  License-Expression: GPL-3.0-or-later
@@ -34,6 +34,7 @@ stepup/core/rpc.py
34
34
  stepup/core/runner.py
35
35
  stepup/core/scheduler.py
36
36
  stepup/core/script.py
37
+ stepup/core/sqlite3.py
37
38
  stepup/core/startup.py
38
39
  stepup/core/step.py
39
40
  stepup/core/stepinfo.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes