File indexing completed on 2026-08-12 09:35:41
0001 """
0002 generation of a shell script to rerun a job interactively
0003
0004 The generated script retrieves the input files and then runs the transformations of the job in an
0005 ALRB container. It takes options to use only a subset of the input files and to read them directly
0006 from storage through a PoolFileCatalog.xml instead of downloading them.
0007
0008 This module intentionally depends only on the standard library, so that it can be used and tested
0009 without a server configuration.
0010 """
0011
0012 import ast
0013 import re
0014 import shlex
0015 from typing import TYPE_CHECKING
0016
0017 if TYPE_CHECKING:
0018 from pandaserver.taskbuffer.JobSpec import JobSpec
0019
0020
0021
0022
0023
0024 _PFC_GENERATOR = r"""import sys
0025
0026 from rucio.client import Client
0027
0028 rse_expression = sys.argv[1] if sys.argv[1] else None
0029 schemes = [s for s in sys.argv[2].split(",") if s]
0030 if not schemes:
0031 schemes = None
0032 dids = [tuple(a.split(":", 1)) for a in sys.argv[3:]]
0033
0034 # GUIDs taken from the PanDA DB. None is resolved with rucio
0035 guids = __GUID_MAP__
0036
0037 client = Client()
0038 guid_map = {}
0039 for scope, lfn in dids:
0040 guid = guids.get(scope + ":" + lfn)
0041 if guid is None:
0042 guid = client.get_metadata(scope, lfn)["guid"]
0043 guid = "-".join([guid[0:8], guid[8:12], guid[12:16], guid[16:20], guid[20:32]])
0044 guid_map[(scope, lfn)] = guid.upper()
0045
0046 candidates = dict([(key, {}) for key in dids])
0047 for replica in client.list_replicas(
0048 [{"scope": scope, "name": lfn} for scope, lfn in dids],
0049 rse_expression=rse_expression,
0050 schemes=schemes,
0051 ):
0052 key = (str(replica["scope"]), str(replica["name"]))
0053 if key in candidates:
0054 candidates[key] = replica.get("pfns") or {}
0055
0056 # exactly one replica per file is required. show the candidates and give up otherwise
0057 bad = [key for key in dids if len(candidates[key]) != 1]
0058 if bad:
0059 for scope, lfn in bad:
0060 pfns = candidates[(scope, lfn)]
0061 if not pfns:
0062 print("ERROR: no replica is available for {0}:{1}".format(scope, lfn))
0063 continue
0064 print("ERROR: {0} replicas are available for {1}:{2}".format(len(pfns), scope, lfn))
0065 for pfn, attrs in sorted(pfns.items(), key=lambda item: item[1].get("priority") or sys.maxsize):
0066 print(" rse={0} domain={1} priority={2} : {3}".format(attrs.get("rse"), attrs.get("domain"), attrs.get("priority"), pfn))
0067 if [key for key in bad if len(candidates[key]) > 1]:
0068 sys.exit("ERROR: narrow down replicas with --rse_expression=<RSE_EXP> and/or --schemes=<SCHEMES>")
0069 sys.exit(1)
0070
0071 with open("PoolFileCatalog.xml", "w") as pfc:
0072 pfc.write("<!-- Edited By POOL -->\n")
0073 pfc.write("<POOLFILECATALOG>\n")
0074 for key in dids:
0075 pfc.write('<File ID="{0}">\n'.format(guid_map[key]))
0076 pfc.write("<physical>\n")
0077 pfc.write('<pfn filetype="ROOT_All" name="{0}"/>\n'.format(list(candidates[key])[0]))
0078 pfc.write("</physical>\n")
0079 pfc.write("<logical/>\n")
0080 pfc.write("</File>\n")
0081 pfc.write("</POOLFILECATALOG>\n")
0082 print("INFO: generated PoolFileCatalog.xml for {0} input file(s)".format(len(dids)))
0083 """
0084
0085
0086 def _get_file_name_pattern(*name_str_list: str) -> re.Pattern:
0087 """
0088 Compile a regex to match one of the strings in trf parameters as an entire file name or list of
0089 file names
0090
0091 The boundaries are checked so that a name is not matched when it is a part of a longer file name.
0092 Note that a name can directly follow a URL-quoted delimiter such as %3D since the trf command is
0093 URL-quoted in the -j option of runAthena/runGen.
0094
0095 Args:
0096 *name_str_list (str): file names or comma-separated lists of file names
0097
0098 Returns:
0099 re.Pattern: the compiled regex. the longest string is matched when they overlap
0100 """
0101 alternatives = "|".join([re.escape(name_str) for name_str in sorted(name_str_list, key=len, reverse=True)])
0102 return re.compile(r"(?:(?<=%[0-9A-Fa-f]{2})|(?<![\w.\-]))(?:" + alternatives + r")(?![\w.\-])")
0103
0104
0105 def _get_input_file_list_in_params(param_str: str, lfn_set: set[str]) -> list[str] | None:
0106 """
0107 Find the list of input files in trf parameters, so that it can be replaced with a shell variable
0108
0109 Two styles are recognized: a python list used by runAthena/runGen (e.g. -i "['a', 'b']"), and a
0110 comma-separated list used by production trfs (e.g. --inputEVNTFile=a,b,c).
0111
0112 Args:
0113 param_str (str): trf parameters of the job
0114 lfn_set (set[str]): LFNs of the input files which may appear in the parameters
0115
0116 Returns:
0117 list[str] | None: the LFNs in the order they appear in the parameters, or None if no list of
0118 input files is found
0119 """
0120
0121 for match in re.finditer(r"\[[^\[\]]*\]", param_str):
0122 try:
0123 lfn_list = ast.literal_eval(match.group(0))
0124 except Exception:
0125 continue
0126 if isinstance(lfn_list, list) and lfn_list and all(isinstance(i, str) and i in lfn_set for i in lfn_list):
0127 return lfn_list
0128
0129 for match in re.finditer(r"--input\w*=\"?([^\s\"']+)", param_str):
0130 lfn_list = match.group(1).split(",")
0131 if all(i in lfn_set for i in lfn_list):
0132 return lfn_list
0133 return None
0134
0135
0136 def _replace_input_file_list_in_params(param_str: str, ordered_lfns: list[str]) -> str:
0137 """
0138 Replace every occurrence of the list of input files in trf parameters with a shell variable
0139
0140 The same list is expanded more than once and in different formats. E.g. runAthena/runGen takes
0141 it as a python list in -i, while the trf command in -j contains it as a comma-separated string
0142 which is URL-quoted together with the rest of the command, i.e.
0143 -i "['a', 'b']" ... -j "athena.py%20--filesInput%3Da,b".
0144
0145 Args:
0146 param_str (str): trf parameters of the job
0147 ordered_lfns (list[str]): LFNs of the input files in the order they appear in the parameters
0148
0149 Returns:
0150 str: the parameters where the list of input files is replaced with a shell variable
0151 """
0152
0153
0154
0155 def replace_list(match: re.Match) -> str:
0156 try:
0157 if ast.literal_eval(match.group(0)) == ordered_lfns:
0158 return "[${input_list}]"
0159 except Exception:
0160 pass
0161 return match.group(0)
0162
0163 param_str = re.sub(r"\[[^\[\]]*\]", replace_list, param_str)
0164
0165 return _get_file_name_pattern(",".join(ordered_lfns)).sub("${input_csv}", param_str)
0166
0167
0168 def generate_offline_run_script(job_spec: "JobSpec") -> str:
0169 """
0170 Generate a shell script to rerun a job interactively
0171
0172 The script retrieves the input files and then runs the transformations of the job in an ALRB
0173 container. It takes options to use only a subset of the input files (--nfiles) and to read them
0174 directly from storage through PoolFileCatalog.xml instead of downloading them (--direct).
0175
0176 Args:
0177 job_spec (JobSpec): job specification with the Files attribute filled in
0178
0179 Returns:
0180 str: the shell script, or a message starting with "ERROR: " when the script cannot be
0181 generated from the job specification
0182 """
0183
0184 is_user = False
0185 for trf in [
0186 "runAthena",
0187 "runGen",
0188 "runcontainer",
0189 "runMerge",
0190 "buildJob",
0191 "buildGen",
0192 ]:
0193 if trf in job_spec.transformation:
0194 is_user = True
0195 break
0196
0197 if job_spec.prodSourceLabel == "user":
0198 is_user = True
0199
0200 atlas_release_str = job_spec.AtlasRelease
0201 if atlas_release_str in [None, "NULL"]:
0202 atlas_release_str = ""
0203 if is_user:
0204 atlas_releases = [atlas_release_str]
0205 home_packages = [re.sub("^AnalysisTransforms-*", "", job_spec.homepackage)]
0206 job_params_list = [job_spec.jobParameters]
0207 transformations = [job_spec.transformation]
0208 else:
0209
0210 atlas_releases = atlas_release_str.split("\n")
0211 home_packages = job_spec.homepackage.split("\n")
0212 job_params_list = job_spec.jobParameters.split("\n")
0213 transformations = job_spec.transformation.split("\n")
0214 if not (len(atlas_releases) == len(home_packages) == len(job_params_list) == len(transformations)):
0215 return "ERROR: The number of releases or parameters or trfs is inconsistent with others"
0216
0217
0218 aux_dids = []
0219 data_dids = {}
0220 guid_map = {}
0221 for tmp_file in job_spec.Files:
0222 if tmp_file.type != "input":
0223 continue
0224 tmp_did = tmp_file.scope + ":" + tmp_file.lfn
0225 if tmp_file.lfn.endswith(".tgz") or tmp_file.lfn.endswith(".tar.gz"):
0226 if tmp_did not in aux_dids:
0227 aux_dids.append(tmp_did)
0228 elif tmp_file.lfn not in data_dids:
0229 data_dids[tmp_file.lfn] = tmp_did
0230 guid_map[tmp_did] = None if tmp_file.GUID in [None, "NULL", ""] else tmp_file.GUID.upper()
0231
0232
0233
0234 ordered_lfns = None
0235 for param_str in job_params_list:
0236 ordered_lfns = _get_input_file_list_in_params(param_str, set(data_dids))
0237 if ordered_lfns:
0238 break
0239 if ordered_lfns:
0240 new_params = [_replace_input_file_list_in_params(param_str, ordered_lfns) for param_str in job_params_list]
0241 leftover_pattern = _get_file_name_pattern(*ordered_lfns)
0242 if any(leftover_pattern.search(tmp_params) for tmp_params in new_params):
0243
0244
0245 ordered_lfns = None
0246 else:
0247 job_params_list = new_params
0248 if ordered_lfns is None:
0249
0250 data_files = list(data_dids)
0251 else:
0252 data_files = [tmp_lfn for tmp_lfn in ordered_lfns if tmp_lfn in data_dids]
0253
0254 aux_dids += [tmp_did for tmp_lfn, tmp_did in data_dids.items() if tmp_lfn not in data_files]
0255
0256 script_str = (
0257 "#!/bin/bash\n\n"
0258 "# To rerun the job interactively :\n"
0259 "# 1) download this script\n"
0260 "# 2) chmod +x ./<this script>\n"
0261 "# 3) setupATLAS\n"
0262 "# 4) ./<this script> [options]\n"
0263 "#\n"
0264 "# Options:\n"
0265 "# --nfiles=<N> use only the first N input files\n"
0266 "# --direct read input files directly from storage instead of\n"
0267 "# downloading them, using PoolFileCatalog.xml\n"
0268 "# --rse_expression=<RSE_EXP> RSE expression to choose replicas for --direct\n"
0269 "# --schemes=<SCHEMES> comma-separated protocols for --direct. default: root\n"
0270 "\n"
0271 'usage() { sed -n "/^# To rerun/,/^$/p" "$0"; }\n\n'
0272 "direct=0\n"
0273 'nfiles=""\n'
0274 'rse_expression=""\n'
0275 'schemes="root"\n'
0276 'direct_opts=""\n'
0277 'for arg in "$@"; do\n'
0278 ' case "$arg" in\n'
0279 )
0280
0281 if is_user:
0282 script_str += ' --direct) direct=1; direct_opts=" --usePFCTurl --directIn" ;;\n'
0283 else:
0284 script_str += " --direct) direct=1 ;;\n"
0285 script_str += (
0286 ' --nfiles=*) nfiles="${arg#*=}" ;;\n'
0287 ' --rse_expression=*) rse_expression="${arg#*=}" ;;\n'
0288 ' --schemes=*) schemes="${arg#*=}" ;;\n'
0289 " -h|--help) usage; exit 0 ;;\n"
0290 ' *) echo "ERROR: unknown option: $arg"; usage; exit 1 ;;\n'
0291 " esac\n"
0292 "done\n\n"
0293 )
0294
0295 script_str += 'if [ -z "$ATLAS_LOCAL_ROOT_BASE" ]; then\n echo "ERROR: setupATLAS is required to run this script"; exit 1\nfi\n\n'
0296
0297 if not data_files:
0298 script_str += (
0299 'if [ -n "$nfiles" ]; then echo "ERROR: --nfiles is not available for this job"; exit 1; fi\n'
0300 'if [ "$direct" -eq 1 ]; then echo "ERROR: --direct is not available for this job"; exit 1; fi\n\n'
0301 )
0302 else:
0303 script_str += "#input files\n"
0304 script_str += "data_dids=(" + " ".join(['"' + data_dids[tmp_lfn] + '"' for tmp_lfn in data_files]) + ")\n"
0305 if ordered_lfns is None:
0306 script_str += 'if [ -n "$nfiles" ]; then\n echo "ERROR: --nfiles is not available for this job"; exit 1\nfi\n'
0307 else:
0308 script_str += 'if [ -n "$nfiles" ]; then\n'
0309 script_str += ' case "$nfiles" in \'\'|*[!0-9]*|0) echo "ERROR: --nfiles must be a positive integer"; exit 1 ;; esac\n'
0310 script_str += " nfiles=$((10#$nfiles))\n"
0311 script_str += ' data_dids=("${data_dids[@]:0:$nfiles}")\n'
0312 script_str += f' echo "INFO: using ${{#data_dids[@]}} of {len(data_files)} input files"\n'
0313 script_str += "fi\n"
0314 script_str += (
0315 "data_lfns=()\n"
0316 'for did in "${data_dids[@]}"; do data_lfns+=("${did#*:}"); done\n'
0317 'input_csv=$(IFS=,; echo "${data_lfns[*]}")\n'
0318 'input_list=$(printf ", \'%s\'" "${data_lfns[@]}")\n'
0319 'input_list="${input_list:2}"\n\n'
0320 )
0321
0322
0323
0324
0325 if data_files or aux_dids:
0326 script_str += "#retrieve inputs\n(\n"
0327 script_str += " source ${ATLAS_LOCAL_ROOT_BASE}/user/atlasLocalSetup.sh --quiet\n lsetup rucio\n"
0328 if data_files:
0329
0330 script_str += (
0331 ' if [ "$direct" -eq 1 ]; then\n'
0332 " #generate PoolFileCatalog.xml with replica PFNs\n"
0333 ' python3 - "$rse_expression" "$schemes" "${data_dids[@]}" << \'PFCEOF\'\n'
0334 )
0335 sub_guid_map = {data_dids[tmp_lfn]: guid_map[data_dids[tmp_lfn]] for tmp_lfn in data_files}
0336 script_str += _PFC_GENERATOR.replace("__GUID_MAP__", repr(sub_guid_map))
0337 script_str += "PFCEOF\n"
0338 script_str += (
0339 " if [ $? -ne 0 ]; then exit 1; fi\n"
0340 " else\n"
0341 ' for did in "${data_dids[@]}"; do\n'
0342 ' rucio download "$did" --no-subdir || exit 1\n'
0343 " done\n"
0344 " fi\n"
0345 )
0346
0347 for tmp_did in aux_dids:
0348 script_str += f' rucio download "{tmp_did}" --no-subdir || exit 1\n'
0349 script_str += ") || exit 1\n\n"
0350 if is_user:
0351 script_str += "#get trf\n"
0352 script_str += f"wget {transformations[0]} || exit 1\n"
0353 script_str += f"chmod +x {transformations[0].split('/')[-1]}\n\n"
0354
0355 script_str += (
0356 "temp_file=$(mktemp)\n"
0357 'cat << EOF > "$temp_file"\n\n'
0358 "source ${ATLAS_LOCAL_ROOT_BASE}/user/atlasLocalSetup.sh\n"
0359 "\n#transform commands\n\n"
0360 )
0361 cmt_config = ""
0362 for tmp_idx, home_package in enumerate(home_packages):
0363
0364 atlas_release = re.sub("Atlas-", "", atlas_releases[tmp_idx])
0365 atlas_tags = re.split("[/_]", home_package)
0366 if "" in atlas_tags:
0367 atlas_tags.remove("")
0368 if atlas_release != "" and atlas_release not in atlas_tags and (re.search(r"^\d+\.\d+\.\d+$", atlas_release) is None or is_user):
0369 atlas_tags.append(atlas_release)
0370 try:
0371 cmt_config = [s for s in job_spec.cmtConfig.split("@") if s][-1]
0372 except Exception:
0373 cmt_config = ""
0374 script_str += f"asetup --platform={job_spec.cmtConfig.split('@')[0]} {','.join(atlas_tags)}\n"
0375
0376 if job_spec.coreCount not in ["NULL", None] and job_spec.coreCount > 1:
0377 script_str += f"export ATHENA_PROC_NUMBER={job_spec.coreCount}\n"
0378 script_str += f"export ATHENA_CORE_NUMBER={job_spec.coreCount}\n"
0379
0380 param_str = job_params_list[tmp_idx]
0381 splitter = shlex.shlex(param_str, posix=True)
0382 splitter.whitespace = " "
0383 splitter.whitespace_split = True
0384
0385 for item in splitter:
0386 match = re.search("^(-[^=]+=)(.+)$", item)
0387 if match is not None:
0388 arg_name = match.group(1)
0389 arg_value = match.group(2)
0390 arg_index = param_str.find(arg_name) + len(arg_name)
0391
0392 if param_str[arg_index] != '"':
0393 param_str = param_str.replace(match.group(0), arg_name + '"' + arg_value + '"')
0394
0395 if is_user:
0396 script_str += "./"
0397 param_str += " --debug${direct_opts}"
0398 script_str += f"{transformations[tmp_idx].split('/')[-1]} {param_str}\n\n"
0399 script_str += 'EOF\n\nchmod +x "$temp_file"\n'
0400 script_str += f'source ${{ATLAS_LOCAL_ROOT_BASE}}/user/atlasLocalSetup.sh -c {cmt_config} -r "$temp_file"\n'
0401 script_str += 'rm "$temp_file"\n'
0402 return script_str