Skip to content

Commit ce43cb7

Browse files
committed
Utility: Introduce command repeater script
Introduce a command repeater script to help run a set of commands repeatedly. Also, enforce black formatting, flake8 linting and mypy type checking on `Utilities/repeat_command` via the `Utilities/build-using-self` script, which is executed in the self-hosted CI pipelines.
1 parent bd1cf1b commit ce43cb7

File tree

3 files changed

+251
-0
lines changed

3 files changed

+251
-0
lines changed

Utilities/build-using-self

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ root_dir="$(cd ${__dir}/.. && pwd)"
1919
cd "${root_dir}/"
2020
echo "Current directory is ${PWD}"
2121

22+
# Check python typing
23+
python_bin=$(command -v python3)
24+
VENV_PATH=$(mktemp -d)
25+
$python_bin -m venv "${VENV_PATH}"
26+
source "${VENV_PATH}"/bin/activate
27+
pip3 install --requirement "${__dir}"/python/requirement.txt
28+
29+
mypy --strict Utilities/repeat_command
30+
black --check Utilities/repeat_command
31+
flake8 --max-line-length 120 Utilities/repeat_command
32+
33+
# Actual pipeline
2234
CONFIGURATION=debug
2335
export SWIFTCI_IS_SELF_HOSTED=1
2436

Utilities/python/requirement.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
black==24.10.0
2+
flake8==7.1.1
3+
mypy==1.14.1

Utilities/repeat_command

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#!/usr/bin/env python3
2+
# ===----------------------------------------------------------------------===##
3+
#
4+
# This source file is part of the Swift open source project
5+
#
6+
# Copyright (c) 2025 Apple Inc. and the Swift project authors
7+
# Licensed under Apache License v2.0 with Runtime Library Exception
8+
#
9+
# See http://swift.org/LICENSE.txt for license information
10+
# See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
11+
#
12+
# ===----------------------------------------------------------------------===##
13+
14+
import argparse
15+
import dataclasses
16+
import datetime
17+
import logging
18+
import os
19+
import pathlib
20+
import shlex
21+
import shutil
22+
import subprocess
23+
import sys
24+
import tempfile
25+
import time
26+
import types
27+
import typing as t
28+
29+
logging.basicConfig(
30+
stream=sys.stdout,
31+
format=" | ".join(
32+
[
33+
"%(asctime)s",
34+
# "%(levelname)-7s",
35+
# "%(module)s",
36+
# "%(funcName)s",
37+
# "Line:%(lineno)d",
38+
"%(message)s",
39+
]
40+
),
41+
level=logging.INFO,
42+
)
43+
44+
45+
def get_command(command: str) -> str:
46+
return f'"{c}"' if (c := shutil.which(command)) else ""
47+
48+
49+
ALWAYS_EXECUTED_COMMANDS: t.Sequence[str] = [
50+
f"{get_command('git')} log -n1",
51+
f"{get_command('swift')} --version",
52+
]
53+
54+
55+
def pad_number(actual: int, max_num: int) -> str:
56+
num_digits = len(str(max_num))
57+
return str(actual).zfill(num_digits)
58+
59+
60+
@dataclasses.dataclass
61+
class Configuration:
62+
logs_path: pathlib.Path
63+
num_iterations: int
64+
is_dryrun: bool
65+
66+
67+
class CommandsRepeater:
68+
def __init__(
69+
self,
70+
commands: t.Sequence[str],
71+
*,
72+
config: Configuration,
73+
) -> None:
74+
self.commands: t.Sequence[str] = commands
75+
self.config = config
76+
77+
@property
78+
def failed_logs_path(self) -> pathlib.Path:
79+
failulre_path = self.config.logs_path / "failed"
80+
if not failulre_path.exists():
81+
os.makedirs(failulre_path, exist_ok=True)
82+
return failulre_path
83+
84+
def _construct_command(self, cmd: str) -> str:
85+
return " ".join(
86+
[
87+
get_command("echo") if self.config.is_dryrun else "",
88+
# get_command("caffeinate"),
89+
cmd,
90+
]
91+
)
92+
93+
def execute_command(self, command: str, *, log_file: pathlib.Path) -> bool:
94+
""" """
95+
with log_file.open("a+") as logfile_fd:
96+
logfile_fd.write(f"❯❯❯ Executing: {command}\n")
97+
logfile_fd.flush()
98+
logging.info(" --> executing command: %s", command)
99+
process_results = subprocess.run(
100+
shlex.split(command),
101+
stdout=logfile_fd,
102+
stderr=subprocess.STDOUT,
103+
shell=False,
104+
)
105+
logfile_fd.write("\n")
106+
logfile_fd.flush()
107+
logging.debug(" --- return code: %d", process_results.returncode)
108+
return process_results.returncode == 0
109+
110+
def run(self) -> None:
111+
self.emit_log_directories()
112+
for number in range(1, self.config.num_iterations + 1):
113+
padded_num = pad_number(number, self.config.num_iterations)
114+
iteration_log_pathname = (
115+
self.config.logs_path
116+
/ padded_num
117+
/ f"swift_test_console_{padded_num}.txt"
118+
)
119+
os.makedirs(iteration_log_pathname.parent, exist_ok=True)
120+
iteration_log_pathname.touch()
121+
logging.info(
122+
"[%s/%d] executing and writing log to %s ...",
123+
padded_num,
124+
self.config.num_iterations,
125+
iteration_log_pathname.parent,
126+
)
127+
start_time = time.time()
128+
command_status = [
129+
self.execute_command(
130+
self._construct_command(cmd), log_file=iteration_log_pathname
131+
)
132+
for cmd in self.commands
133+
]
134+
135+
if not all(command_status):
136+
# command failed. so create a symlink
137+
os.symlink(
138+
iteration_log_pathname.parent,
139+
self.failed_logs_path / padded_num,
140+
target_is_directory=True,
141+
)
142+
143+
end_time = time.time()
144+
elapsed_time_seconds = end_time - start_time
145+
elapsed_time = datetime.timedelta(seconds=elapsed_time_seconds)
146+
logging.info(
147+
"[%s/%d] executing and writing log to %s completed in %s",
148+
padded_num,
149+
self.config.num_iterations,
150+
iteration_log_pathname.parent,
151+
elapsed_time,
152+
)
153+
154+
def __enter__(self) -> "CommandsRepeater":
155+
return self
156+
157+
def __exit__(
158+
self,
159+
exc_type: t.AbstractSet[t.Type[BaseException]],
160+
exc_inst: t.Optional[BaseException],
161+
exc_tb: t.Optional[types.TracebackType],
162+
) -> bool:
163+
logging.info("-" * 100)
164+
self.emit_log_directories()
165+
return True
166+
167+
def emit_log_directories(self) -> None:
168+
logging.info("Root Log Directory : %s", self.config.logs_path.resolve())
169+
logging.info("Failed Log Directory: %s", self.failed_logs_path.resolve())
170+
171+
172+
def main() -> None:
173+
parser = argparse.ArgumentParser(
174+
formatter_class=argparse.ArgumentDefaultsHelpFormatter
175+
)
176+
parser.add_argument(
177+
"--verbose",
178+
dest="is_verbose",
179+
action="store_true",
180+
help="When set, prints verbose information.",
181+
)
182+
parser.add_argument(
183+
"--dry-run",
184+
dest="is_dryrun",
185+
action="store_true",
186+
help="When set, print the commands that will be executed",
187+
)
188+
parser.add_argument(
189+
"-l",
190+
"--logs-dir",
191+
dest="logs_path",
192+
help="The directory to store the logs files",
193+
type=pathlib.Path,
194+
)
195+
parser.add_argument(
196+
"-n",
197+
"--number-iterations",
198+
"--iterations",
199+
dest="num_iterations",
200+
type=int,
201+
help="The number of iterations to runs the set of commands",
202+
default=200,
203+
)
204+
parser.add_argument(
205+
"--command",
206+
action="append",
207+
help="The command to executes. Accepted multiple times.",
208+
default=ALWAYS_EXECUTED_COMMANDS,
209+
)
210+
211+
args = parser.parse_args()
212+
logging.getLogger().setLevel(logging.DEBUG if args.is_verbose else logging.INFO)
213+
214+
logging.debug(f"args: {args}")
215+
config = Configuration(
216+
logs_path=(args.logs_path or get_default_log_directory()).resolve(),
217+
num_iterations=args.num_iterations,
218+
is_dryrun=args.is_dryrun,
219+
)
220+
221+
if config.logs_path.exists():
222+
logging.debug("logs directory %s exists. deleting...", config.logs_path)
223+
shutil.rmtree(config.logs_path)
224+
225+
with CommandsRepeater(args.command, config=config) as repeater:
226+
repeater.run()
227+
228+
229+
def get_default_log_directory() -> pathlib.Path:
230+
current_time = datetime.datetime.now(datetime.timezone.utc)
231+
time_string = current_time.strftime("%Y%m%dT%H%M%S%Z")
232+
return pathlib.Path(tempfile.TemporaryDirectory(prefix=time_string).name)
233+
234+
235+
if __name__ == "__main__":
236+
main()

0 commit comments

Comments
 (0)