File: C:/Users/fred/anaconda3/Lib/site-packages/conda_build/variants.py
# Copyright (C) 2014 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
"""This file handles the parsing of feature specifications from files,
ending up with a configuration matrix"""
from __future__ import annotations
import os.path
import re
import sys
from collections import OrderedDict
from copy import copy
from functools import lru_cache
from itertools import product
from pathlib import Path
from typing import TYPE_CHECKING
import yaml
from conda.base.context import context
from .utils import ensure_list, get_logger, islist, on_win, trim_empty_keys
from .version import _parse as parse_version
if TYPE_CHECKING:
from typing import Any, Iterable
DEFAULT_VARIANTS = {
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
"numpy": {
# (python): numpy_version, # range of versions built for given python
(3, 8): "1.22", # 1.19-1.24
(3, 9): "1.22", # 1.19-1.26
(3, 10): "1.22", # 1.21-1.26
(3, 11): "1.23", # 1.23-1.26
(3, 12): "1.26", # 1.26-
}.get(sys.version_info[:2], "1.26"),
# this one actually needs to be pretty specific. The reason is that cpan skeleton uses the
# version to say what's in their standard library.
"perl": "5.26.2",
"lua": "5",
"r_base": "3.4" if on_win else "3.5",
"cpu_optimization_target": "nocona",
"pin_run_as_build": {
"python": {"min_pin": "x.x", "max_pin": "x.x"},
"r-base": {"min_pin": "x.x", "max_pin": "x.x"},
},
"ignore_version": [],
"ignore_build_only_deps": ["python", "numpy"],
"extend_keys": [
"pin_run_as_build",
"ignore_version",
"ignore_build_only_deps",
"extend_keys",
],
"cran_mirror": "https://cran.r-project.org",
}
# map python version to default compiler on windows, to match upstream python
# This mapping only sets the "native" compiler, and can be overridden by specifying a compiler
# in the conda-build variant configuration
DEFAULT_COMPILERS = {
"win": {
"c": {
"2.7": "vs2008",
"3.3": "vs2010",
"3.4": "vs2010",
"3.5": "vs2017",
},
"cxx": {
"2.7": "vs2008",
"3.3": "vs2010",
"3.4": "vs2010",
"3.5": "vs2017",
},
"vc": {
"2.7": "9",
"3.3": "10",
"3.4": "10",
"3.5": "14",
},
"fortran": "gfortran",
},
"linux": {
"c": "gcc",
"cxx": "gxx",
"fortran": "gfortran",
},
"osx": {
"c": "clang",
"cxx": "clangxx",
"fortran": "gfortran",
},
}
arch_name = context.subdir.rsplit("-", 1)[-1]
SUFFIX_MAP = {
"PY": "python",
"NPY": "numpy",
"LUA": "lua",
"PERL": "perl",
"R": "r_base",
}
@lru_cache(maxsize=None)
def _get_default_compilers(platform, py_ver):
compilers = DEFAULT_COMPILERS[platform].copy()
if platform == "win":
if parse_version(py_ver) >= parse_version("3.5"):
py_ver = "3.5"
elif parse_version(py_ver) <= parse_version("3.2"):
py_ver = "2.7"
compilers["c"] = compilers["c"][py_ver]
compilers["cxx"] = compilers["cxx"][py_ver]
compilers = {
lang + "_compiler": pkg_name
for lang, pkg_name in compilers.items()
if lang != "vc"
}
# this one comes after, because it's not a _compiler key
if platform == "win":
compilers["vc"] = DEFAULT_COMPILERS[platform]["vc"][py_ver]
return compilers
def get_default_variant(config):
base = DEFAULT_VARIANTS.copy()
base["target_platform"] = config.subdir
python = (
base["python"]
if (not hasattr(config, "variant") or not config.variant.get("python"))
else config.variant["python"]
)
base.update(_get_default_compilers(config.platform, python))
return base
def parse_config_file(path, config):
from .metadata import get_selectors, select_lines
with open(path) as f:
contents = f.read()
contents = select_lines(contents, get_selectors(config), variants_in_place=False)
content = yaml.load(contents, Loader=yaml.loader.BaseLoader) or {}
trim_empty_keys(content)
return content
def validate_spec(src, spec):
errors = []
# check for invalid characters
errors.extend(
f" {k} key contains an invalid character '-'" for k in spec if "-" in k
)
# check for properly formatted zip_key
try:
zip_keys = _get_zip_keys(spec)
except ValueError as e:
errors.append(str(e))
else:
# check if every zip field is defined
errors.extend(
f" zip_key entry {k} in group {zg} does not have any settings"
for zg in zip_keys
for k in zg
# include error if key is not defined in spec
if k not in spec
)
# check for duplicate keys
unique = set()
errors.extend(
f" zip_key entry {k} in group {zg} is a duplicate, keys can only occur "
"in one group"
# include error if key has already been seen, otherwise add to unique keys
if k in unique
else unique.add(k)
for zg in zip_keys
for k in zg
)
# check that all zip fields within a zip_group are the same length
errors.extend(
f" zip fields in zip_key group {zg} are not all the same length"
for zg in zip_keys
# include error if all zip fields in a zip_group are the same size,
# ignore missing fields
if len(
{len(ensure_list(spec[k])) if k in spec else None for k in zg} - {None}
)
> 1
)
# filter out None values that were potentially added above
errors = list(filter(None, errors))
if errors:
raise ValueError(
"Variant configuration errors in {}:\n{}".format(src, "\n".join(errors))
)
def find_config_files(metadata_or_path, config):
"""
Find config files to load. Config files are stacked in the following order:
1. exclusive config files (see config.exclusive_config_files)
2. user config files
(see context.conda_build["config_file"] or ~/conda_build_config.yaml)
3. cwd config files (see ./conda_build_config.yaml)
4. recipe config files (see ${RECIPE_DIR}/conda_build_config.yaml)
5. additional config files (see config.variant_config_files)
.. note::
Order determines clobbering with later files clobbering earlier ones.
:param metadata_or_path: the metadata or path within which to find recipe config files
:type metadata_or_path:
:param config: config object specifying config file settings
(see exclusive_config_files, ignore_system_variants, and variant_config_files)
:type config: :class:`Config`
:return: List of config files
:rtype: `list` of paths (`str`)
"""
resolve = lambda p: os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
# exclusive configs
files = [resolve(f) for f in ensure_list(config.exclusive_config_files)]
if not files and not config.ignore_system_variants:
# user config
if config_file := context.conda_build.get("config_file"):
cfg = resolve(config_file)
else:
cfg = resolve(os.path.join("~", "conda_build_config.yaml"))
if os.path.isfile(cfg):
files.append(cfg)
cfg = resolve("conda_build_config.yaml")
if os.path.isfile(cfg):
files.append(cfg)
path = getattr(metadata_or_path, "path", metadata_or_path)
cfg = resolve(os.path.join(path, "conda_build_config.yaml"))
if os.path.isfile(cfg):
files.append(cfg)
files.extend([resolve(f) for f in ensure_list(config.variant_config_files)])
return files
def _combine_spec_dictionaries(
specs, extend_keys=None, filter_keys=None, zip_keys=None, log_output=True
):
# each spec is a dictionary. Each subsequent spec replaces the previous one.
# Only the last one with the key stays.
values = {}
keys = ensure_list(filter_keys)
extend_keys = ensure_list(extend_keys)
for spec_source, spec in specs.items():
if spec:
if log_output:
log = get_logger(__name__)
log.info(f"Adding in variants from {spec_source}")
for k, v in spec.items():
if not keys or k in keys:
if k in extend_keys:
# update dictionaries, extend lists
if hasattr(v, "keys"):
if k in values and hasattr(values[k], "keys"):
values[k].update(v)
else:
values[k] = v.copy()
else:
values[k] = ensure_list(values.get(k, []))
values[k].extend(ensure_list(v))
# uniquify
values[k] = list(set(values[k]))
elif k == "zip_keys":
v = [subval for subval in v if subval]
if not isinstance(v[0], list) and not isinstance(v[0], tuple):
v = [v]
# should always be a list of lists, but users may specify as just a list
values[k] = values.get(k, [])
values[k].extend(v)
values[k] = list(
list(set_group)
for set_group in {tuple(group) for group in values[k]}
)
else:
if hasattr(v, "keys"):
values[k] = v.copy()
else:
# default "group" is just this one key. We latch onto other groups if
# they exist
keys_in_group = [k]
if zip_keys:
for group in zip_keys:
if k in group:
keys_in_group = group
break
# in order to clobber, one must replace ALL of the zipped keys.
# or the length must match with the other items in the group
# Otherwise, we filter later.
if all(group_item in spec for group_item in keys_in_group):
for group_item in keys_in_group:
if len(ensure_list(spec[group_item])) != len(
ensure_list(v)
):
raise ValueError(
f"All entries associated by a zip_key "
f"field must be the same length. In {spec_source}, {k} and {group_item} "
f"are different ({len(ensure_list(v))} and "
f"{len(ensure_list(spec[group_item]))})"
)
values[group_item] = ensure_list(spec[group_item])
elif k in values:
for group_item in keys_in_group:
if group_item in spec and len(
ensure_list(spec[group_item])
) != len(ensure_list(v)):
break
if group_item in values and len(
ensure_list(values[group_item])
) != len(ensure_list(v)):
break
else:
values[k] = v.copy()
missing_subvalues = [
subvalue
for subvalue in ensure_list(v)
if subvalue not in values[k]
]
missing_group_items = [
group_item
for group_item in keys_in_group
if group_item not in spec
]
if len(missing_subvalues):
raise ValueError(
f"variant config in {spec_source} is ambiguous because it does not fully "
f"implement all zipped keys (missing {missing_group_items}) or specifies a "
f"subspace that is not fully implemented (we did not find {missing_subvalues} "
f"from {spec} in {k}:{values[k]})."
)
return values
def combine_specs(specs, log_output=True):
"""With arbitrary sets of sources, combine into a single aggregate spec.
Later specs in the input set have priority and overwrite duplicate entries.
specs: list of dictionaries. Keys are arbitrary, but correspond to variable
names used in Jinja2 templated recipes. Values can be either single
values (strings or integers), or collections (lists, tuples, sets).
"""
extend_keys = DEFAULT_VARIANTS["extend_keys"][:]
extend_keys.extend(
[
key
for spec in specs.values()
if spec
for key in ensure_list(spec.get("extend_keys"))
]
)
# first pass gets zip_keys entries from each and merges them. We treat these specially
# below, keeping the size of related fields identical, or else the zipping makes no sense
zip_keys = _combine_spec_dictionaries(
specs, extend_keys=extend_keys, filter_keys=["zip_keys"], log_output=log_output
).get("zip_keys", [])
values = _combine_spec_dictionaries(
specs, extend_keys=extend_keys, zip_keys=zip_keys, log_output=log_output
)
return values
def set_language_env_vars(variant):
"""Given args passed into conda command, set language env vars to be made available.
Search terms: CONDA_PY, CONDA_R, CONDA_PERL, CONDA_LUA, CONDA_NPY
"""
inverse_map = {v: k for k, v in SUFFIX_MAP.items()}
env = {}
for variant_name, env_var_name in inverse_map.items():
if variant_name in variant:
value = str(variant[variant_name])
# legacy compatibility: python should be just first
if env_var_name == "PY":
value = "".join(value.split(".")[:2])
env["CONDA_" + env_var_name] = value
return env
def _get_zip_keys(spec):
"""
Extracts 'zip_keys' from `spec` and standardizes value into a list of zip_groups
(tuples of keys (string)).
:param spec: Variants specification
:type spec: dict
:return: Standardized 'zip_keys' value
:rtype: set
:raise ValueError: 'zip_keys' cannot be standardized
"""
zip_keys = spec.get("zip_keys")
if not zip_keys:
return set()
elif islist(zip_keys, uniform=lambda e: isinstance(e, str)):
return {frozenset(zip_keys)}
elif islist(
zip_keys, uniform=lambda e: islist(e, uniform=lambda e: isinstance(e, str))
):
return {frozenset(zg) for zg in zip_keys}
raise ValueError("'zip_keys' expect list of string or list of lists of string")
def _get_extend_keys(spec, include_defaults=True):
"""
Extracts 'extend_keys' from `spec`.
:param spec: Variants specification
:type spec: dict
:param include_defaults: Whether to include default 'extend_keys'
:type include_defaults: bool, optional
:return: Standardized 'extend_keys' value
:rtype: set
"""
extend_keys = {"zip_keys", "extend_keys"}
if include_defaults:
extend_keys.update(DEFAULT_VARIANTS["extend_keys"])
return extend_keys.union(ensure_list(spec.get("extend_keys")))
def _get_passthru_keys(spec, zip_keys=None, extend_keys=None):
"""
Keys in `spec` that are not exploded and are simply carried over from the `spec`
into the variants without modification.
:param spec: Variants specification
:type spec: dict
:param zip_keys: Keys defined as 'zip_keys' (see :func:`_get_zip_keys`)
:type zip_keys: set, optional
:param extend_keys: Keys defined as 'extend_keys' (see :func:`_get_extend_keys`)
:type extend_keys: set, optional
:return: Passthru (not exploded) keys defined in `spec`
:rtype: set
"""
if zip_keys is None:
zip_keys = _get_zip_keys(spec)
if extend_keys is None:
extend_keys = _get_extend_keys(spec)
passthru_keys = {"replacements", "extend_keys", "zip_keys"}
return passthru_keys.union(extend_keys).difference(*zip_keys).intersection(spec)
def _get_explode_keys(spec, passthru_keys=None, zip_keys=None, extend_keys=None):
"""
Keys in `spec` that are that are exploding into the variants.
:param spec: Variants specification
:type spec: dict
:param passthru_keys: Passthru (not exploded) keys (see :func:`_get_passthru_keys`)
:type passthru_keys: set, optional
:param zip_keys: Keys defined as 'zip_keys' (see :func:`_get_zip_keys`) and is passed
to :func:`_get_passthru_keys` if `passthru_keys` is undefined
:type zip_keys: set, optional
:param extend_keys: Keys defined as 'extend_keys' (see :func:`_get_extend_keys`) and
is passed to :func:`_get_passthru_keys` if `passthru_keys` is
undefined
:type extend_keys: set, optional
:return: Exploded keys defined in `spec`
:rtype: set
"""
if passthru_keys is None:
passthru_keys = _get_passthru_keys(spec, zip_keys, extend_keys)
return set(spec).difference(passthru_keys)
def filter_by_key_value(variants, key, values, source_name):
"""variants is the exploded out list of dicts, with one value per key in each dict.
key and values come from subsequent variants before they are exploded out."""
reduced_variants = []
if hasattr(values, "keys"):
reduced_variants = variants
else:
# break this out into a full loop so that we can show filtering output
for variant in variants:
if variant.get(key) is not None and variant.get(key) in values:
reduced_variants.append(variant)
else:
log = get_logger(__name__)
log.debug(
f"Filtering variant with key {key} not matching target value(s) "
f"({values}) from {source_name}, actual {variant.get(key)}"
)
return reduced_variants
@lru_cache(maxsize=None)
def _split_str(string, char):
return string.split(char)
def explode_variants(spec):
"""
Helper function to explode spec into all of the variants.
.. code-block:: pycon
>>> spec = {
... # normal expansions
... "foo": [2.7, 3.7, 3.8],
... # zip_keys are the values that need to be exploded as a set
... "zip_keys": [["bar", "baz"], ["qux", "quux", "quuz"]],
... "bar": [1, 2, 3],
... "baz": [2, 4, 6],
... "qux": [4, 5],
... "quux": [8, 10],
... "quuz": [12, 15],
... # extend_keys are those values which we do not explode
... "extend_keys": ["corge"],
... "corge": 42,
... }
>>> explode_variants(spec)
[{
"foo": 2.7,
"bar": 1, "baz": 2,
"qux": 4, "quux": 8, "quuz": 12,
"corge": 42,
"zip_keys": ..., "extend_keys": ...,
},
{
"foo": 2.7,
"bar": 1, "baz": 2,
"qux": 5, "quux": 10, "quuz": 15,
"corge": 42,
...,
}, ...]
:param spec: Specification to explode
:type spec: `dict`
:return: Exploded specification
:rtype: `list` of `dict`
"""
zip_keys = _get_zip_keys(spec)
# key/values from spec that do not explode
passthru_keys = _get_passthru_keys(spec, zip_keys)
passthru = {k: spec[k] for k in passthru_keys if spec[k] or spec[k] == ""}
# key/values from spec that do explode
explode_keys = _get_explode_keys(spec, passthru_keys)
explode = {
(k,): [ensure_list(v, include_dict=False) for v in ensure_list(spec[k])]
for k in explode_keys.difference(*zip_keys)
}
explode.update(
{zg: list(zip(*(ensure_list(spec[k]) for k in zg))) for zg in zip_keys}
)
trim_empty_keys(explode)
# Cartesian Product of dict of lists
# http://stackoverflow.com/a/5228294/1170370
# dict.keys() and dict.values() orders are the same even prior to Python 3.6
variants = []
for values in product(*explode.values()):
variant = {k: copy(v) for k, v in passthru.items()}
variant.update(
{k: v for zg, zv in zip(explode, values) for k, v in zip(zg, zv)}
)
variants.append(variant)
return variants
# temporary backport for other places in cond_build
dict_of_lists_to_list_of_dicts = explode_variants
def list_of_dicts_to_dict_of_lists(list_of_dicts):
"""Opposite of dict_of_lists_to_list_of_dicts function.
Take broken out collection of variants, and squish it into a dict, where each value is a list.
Only squishes string/int values; does "update" for dict keys
"""
if not list_of_dicts:
return
squished = OrderedDict()
all_zip_keys = set()
groups = None
zip_key_groups = (
list_of_dicts[0]["zip_keys"]
if "zip_keys" in list_of_dicts[0] and list_of_dicts[0]["zip_keys"]
else []
)
if zip_key_groups:
if isinstance(list_of_dicts[0]["zip_keys"][0], list) or isinstance(
list_of_dicts[0]["zip_keys"][0], tuple
):
groups = list_of_dicts[0]["zip_keys"]
else:
groups = [list_of_dicts[0]["zip_keys"]]
for group in groups:
for item in group:
all_zip_keys.add(item)
for variant in list_of_dicts:
for k, v in variant.items():
if k == "zip_keys":
continue
if hasattr(v, "keys"):
existing_value = squished.get(k, OrderedDict())
existing_value.update(v)
squished[k] = existing_value
elif isinstance(v, list):
squished[k] = set(squished.get(k, set())) | set(v)
else:
squished[k] = list(squished.get(k, [])) + ensure_list(v)
if k not in all_zip_keys:
squished[k] = list(set(squished[k]))
# reduce the combinatoric space of the zipped keys, too:
if groups:
for group in groups:
values = list(zip(*set(zip(*(squished[key] for key in group)))))
for idx, key in enumerate(group):
squished[key] = values[idx]
squished["zip_keys"] = zip_key_groups
return squished
def get_package_combined_spec(recipedir_or_metadata, config=None, variants=None):
# outputs a tuple of (combined_spec_dict_of_lists, used_spec_file_dict)
#
# The output of this function is order preserving, unlike get_package_variants
if hasattr(recipedir_or_metadata, "config"):
config = recipedir_or_metadata.config
if not config:
from .config import Config
config = Config()
files = find_config_files(recipedir_or_metadata, config)
specs = OrderedDict(internal_defaults=get_default_variant(config))
for f in files:
specs[f] = parse_config_file(f, config)
# this is the override of the variants from files and args with values from CLI or env vars
if hasattr(config, "variant") and config.variant:
specs["config.variant"] = config.variant
if variants:
specs["argument_variants"] = variants
for f, spec in specs.items():
validate_spec(f, spec)
# this merges each of the specs, providing a debug message when a given setting is overridden
# by a later spec
combined_spec = combine_specs(specs, log_output=config.verbose)
return combined_spec, specs
def filter_combined_spec_to_used_keys(combined_spec, specs):
extend_keys = _get_extend_keys(combined_spec)
# delete the default specs, so that they don't unnecessarily limit the matrix
specs = specs.copy()
del specs["internal_defaults"]
# TODO: act here?
combined_spec = explode_variants(combined_spec)
# seen_keys makes sure that a setting from a lower-priority spec doesn't clobber
# the same setting that has been redefined in a higher-priority spec.
seen_keys = set()
# The specs are checked from high to low priority order.
for source, source_specs in reversed(specs.items()):
for k, vs in source_specs.items():
if k not in extend_keys and k not in seen_keys:
# when filtering ends up killing off all variants, we just ignore that. Generally,
# this arises when a later variant config overrides, rather than selects a
# subspace of earlier configs
combined_spec = (
filter_by_key_value(combined_spec, k, vs, source_name=source)
or combined_spec
)
seen_keys.add(k)
return combined_spec
def get_package_variants(recipedir_or_metadata, config=None, variants=None):
combined_spec, specs = get_package_combined_spec(
recipedir_or_metadata, config=config, variants=variants
)
return filter_combined_spec_to_used_keys(combined_spec, specs=specs)
def get_vars(
variants: Iterable[dict[str, Any]],
subset: set[str] | None = None,
) -> set[str]:
"""For purposes of naming/identifying, provide a way of identifying which variables contribute
to the matrix dimensionality"""
first, *others = variants
special_keys = {
"pin_run_as_build",
"zip_keys",
"ignore_version",
*ensure_list(first.get("extend_keys")),
}
to_consider = set(first)
if subset is not None:
to_consider.intersection_update(subset)
to_consider.difference_update(special_keys)
return {
var for var in to_consider if any(first[var] != other[var] for other in others)
}
@lru_cache(maxsize=None)
def find_used_variables_in_text(variant, recipe_text, selectors_only=False):
used_variables = set()
recipe_lines = recipe_text.splitlines()
for v in variant:
all_res = []
target_match = re.match(r"(.*?)_(compiler|stdlib)(_version)?$", v)
if target_match and not selectors_only:
target_lang = target_match.group(1)
target_kind = target_match.group(2)
target_lang_regex = re.escape(target_lang)
target_regex = (
rf"\{{\s*{target_kind}\([\'\"]{target_lang_regex}[\"\'][^\{{]*?\}}"
)
all_res.append(target_regex)
variant_lines = [
line for line in recipe_lines if v in line or target_lang in line
]
else:
variant_lines = [
line for line in recipe_lines if v in line.replace("-", "_")
]
if not variant_lines:
continue
v_regex = re.escape(v)
v_req_regex = "[-_]".join(map(re.escape, v.split("_")))
variant_regex = rf"\{{\s*(?:pin_[a-z]+\(\s*?['\"])?{v_regex}[^'\"]*?\}}\}}"
selector_regex = rf"^[^#\[]*?\#?\s\[[^\]]*?(?<![_\w\d]){v_regex}[=\s<>!\]]"
# NOTE: why use a regex instead of the jinja2 parser/AST?
# One can ask the jinja2 parser for undefined variables, but conda-build moves whole
# blocks of text around when searching for variables and applies selectors to the text.
# So the text that reaches this function is not necessarily valid jinja2 syntax. :/
conditional_regex = (
r"(?:^|[^\{])\{%\s*(?:el)?if\s*.*" + v_regex + r"\s*(?:[^%]*?)?%\}"
)
# TODO: this `for` regex won't catch some common cases like lists of vars, multiline
# jinja2 blocks, if filters on the for loop, etc.
for_regex = r"(?:^|[^\{])\{%\s*for\s*.*\s*in\s*" + v_regex + r"(?:[^%]*?)?%\}"
set_regex = r"(?:^|[^\{])\{%\s*set\s*.*\s*=\s*.*" + v_regex + r"(?:[^%]*?)?%\}"
# plain req name, no version spec. Look for end of line after name, or comment or selector
requirement_regex = rf"^\s+\-\s+{v_req_regex}\s*(?:\s[\[#]|$)"
if selectors_only:
all_res.insert(0, selector_regex)
else:
all_res.extend(
[
variant_regex,
requirement_regex,
conditional_regex,
for_regex,
set_regex,
]
)
# consolidate all re's into one big one for speedup
all_res = r"|".join(all_res)
if any(re.search(all_res, line) for line in variant_lines):
used_variables.add(v)
if v in ("c_stdlib", "c_compiler", "cxx_compiler"):
if "CONDA_BUILD_SYSROOT" in variant:
used_variables.add("CONDA_BUILD_SYSROOT")
return used_variables
def find_used_variables_in_shell_script(
variants: Iterable[str],
file_path: str | os.PathLike | Path,
) -> set[str]:
text = Path(file_path).read_text()
return {
variant
for variant in variants
if (
variant in text # str in str is faster than re.search
and re.search(
rf"(^[^$]*?\$\{{?\s*{re.escape(variant)}\s*[\s|\}}])",
text,
flags=re.MULTILINE | re.DOTALL,
)
)
}
def find_used_variables_in_batch_script(
variants: Iterable[str],
file_path: str | os.PathLike | Path,
) -> set[str]:
text = Path(file_path).read_text()
return {
variant
for variant in variants
if (
variant in text # str in str is faster than re.search
and re.search(
rf"\%{re.escape(variant)}\%",
text,
flags=re.MULTILINE | re.DOTALL,
)
)
}