Back to home page

EIC code displayed by LXR

 
 

    


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 # python script embedded in the offline running script to generate PoolFileCatalog.xml for direct access.
0021 # it takes an RSE expression, comma-separated protocol schemes, and DIDs of input files as arguments.
0022 # note that this snippet runs with the python3 of the ALRB environment, hence it is deliberately kept
0023 # independent of the conventions of this module
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     # python list style used by runAthena/runGen, e.g. -i "['a', 'b']"
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     # comma separated style used by production trfs, e.g. --inputEVNTFile=a,b,c
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     # python list style. other lists are kept intact, e.g. the secondary input stream of
0154     # --inMap "{'IN': [...], 'IN2': [...]}" and the output map of -o "{'X': [('a', 'b')]}"
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     # comma separated style
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     # user job
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     # check prodSourceLabel
0197     if job_spec.prodSourceLabel == "user":
0198         is_user = True
0199     # the release is optional, i.e. it can be NULL in the DB
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         # release and trf
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     # collect inputs. archives (lib.tgz, DBRelease, ...) are always downloaded since
0217     # they cannot be read directly from storage
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     # replace the list of input files in the trf parameters with a shell variable, so that
0232     # --nfiles can shrink it when the script runs. only the first list is subject to --nfiles when
0233     # multiple lists are found, e.g. for secondary input datasets
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             # some input files are still hardcoded in the parameters, i.e. the list appears in an
0244             # unsupported format somewhere, so that --nfiles cannot be supported
0245             ordered_lfns = None
0246         else:
0247             job_params_list = new_params
0248     if ordered_lfns is None:
0249         # the list was not found in the parameters, i.e. --nfiles cannot be supported
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         # input files which don't appear in the parameters are simply downloaded
0254         aux_dids += [tmp_did for tmp_lfn, tmp_did in data_dids.items() if tmp_lfn not in data_files]
0255     # construct script
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     # --usePFCTurl and --directIn are understood only by the trfs for analysis jobs
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     # setupATLAS is required both to retrieve the input files and to setup the container
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     # list of input files which are subject to --nfiles and --direct
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     # retrieve inputs. the current directory is shared with the ALRB container, so that the trf sees
0322     # the downloaded files and PoolFileCatalog.xml. rucio is setup in a subshell to keep its
0323     # environment out of the container setup and the transformations. ALRB is setup there as well
0324     # since lsetup is a shell function which is not necessarily inherited by this script
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             # generate the file catalog for direct access
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         # archives (lib.tgz, DBRelease, ...) are always downloaded
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     # the transformations run in an ALRB container
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         # asetup
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         # athenaMP
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         # add double quotes for zsh
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         # loop for params
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                 # add "
0392                 if param_str[arg_index] != '"':
0393                     param_str = param_str.replace(match.group(0), arg_name + '"' + arg_value + '"')
0394         # run trf
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