summaryrefslogtreecommitdiffstats
path: root/python/mozperftest/mozperftest/utils.py
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--python/mozperftest/mozperftest/utils.py478
1 files changed, 478 insertions, 0 deletions
diff --git a/python/mozperftest/mozperftest/utils.py b/python/mozperftest/mozperftest/utils.py
new file mode 100644
index 0000000000..ff2c0b5faa
--- /dev/null
+++ b/python/mozperftest/mozperftest/utils.py
@@ -0,0 +1,478 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+import contextlib
+import functools
+import importlib
+import logging
+import os
+import shlex
+import shutil
+import subprocess
+import sys
+import tempfile
+from collections import defaultdict
+from datetime import date, datetime, timedelta
+from io import StringIO
+from pathlib import Path
+
+import requests
+from redo import retry
+from requests.packages.urllib3.util.retry import Retry
+
+RETRY_SLEEP = 10
+API_ROOT = "https://firefox-ci-tc.services.mozilla.com/api/index/v1"
+MULTI_REVISION_ROOT = f"{API_ROOT}/namespaces"
+MULTI_TASK_ROOT = f"{API_ROOT}/tasks"
+ON_TRY = "MOZ_AUTOMATION" in os.environ
+DOWNLOAD_TIMEOUT = 30
+
+
+@contextlib.contextmanager
+def silence(layer=None):
+ if layer is None:
+ to_patch = (MachLogger,)
+ else:
+ to_patch = (MachLogger, layer)
+
+ meths = ("info", "debug", "warning", "error", "log")
+ patched = defaultdict(dict)
+
+ oldout, olderr = sys.stdout, sys.stderr
+ sys.stdout, sys.stderr = StringIO(), StringIO()
+
+ def _vacuum(*args, **kw):
+ sys.stdout.write(str(args))
+
+ for obj in to_patch:
+ for meth in meths:
+ if not hasattr(obj, meth):
+ continue
+ patched[obj][meth] = getattr(obj, meth)
+ setattr(obj, meth, _vacuum)
+
+ stdout = stderr = None
+ try:
+ sys.stdout.buffer = sys.stdout
+ sys.stderr.buffer = sys.stderr
+ sys.stdout.fileno = sys.stderr.fileno = lambda: -1
+ try:
+ yield sys.stdout, sys.stderr
+ except Exception:
+ sys.stdout.seek(0)
+ stdout = sys.stdout.read()
+ sys.stderr.seek(0)
+ stderr = sys.stderr.read()
+ raise
+ finally:
+ sys.stdout, sys.stderr = oldout, olderr
+ for obj, meths in patched.items():
+ for name, old_func in meths.items():
+ try:
+ setattr(obj, name, old_func)
+ except Exception:
+ pass
+ if stdout is not None:
+ print(stdout)
+ if stderr is not None:
+ print(stderr)
+
+
+def simple_platform():
+ plat = host_platform()
+
+ if plat.startswith("win"):
+ return "win"
+ elif plat.startswith("linux"):
+ return "linux"
+ else:
+ return "mac"
+
+
+def host_platform():
+ is_64bits = sys.maxsize > 2 ** 32
+
+ if sys.platform.startswith("win"):
+ if is_64bits:
+ return "win64"
+ elif sys.platform.startswith("linux"):
+ if is_64bits:
+ return "linux64"
+ elif sys.platform.startswith("darwin"):
+ return "darwin"
+
+ raise ValueError(f"platform not yet supported: {sys.platform}")
+
+
+class MachLogger:
+ """Wrapper around the mach logger to make logging simpler."""
+
+ def __init__(self, mach_cmd):
+ self._logger = mach_cmd.log
+
+ @property
+ def log(self):
+ return self._logger
+
+ def info(self, msg, name="mozperftest", **kwargs):
+ self._logger(logging.INFO, name, kwargs, msg)
+
+ def debug(self, msg, name="mozperftest", **kwargs):
+ self._logger(logging.DEBUG, name, kwargs, msg)
+
+ def warning(self, msg, name="mozperftest", **kwargs):
+ self._logger(logging.WARNING, name, kwargs, msg)
+
+ def error(self, msg, name="mozperftest", **kwargs):
+ self._logger(logging.ERROR, name, kwargs, msg)
+
+
+def install_package(virtualenv_manager, package, ignore_failure=False):
+ """Installs a package using the virtualenv manager.
+
+ Makes sure the package is really installed when the user already has it
+ in their local installation.
+
+ Returns True on success, or re-raise the error. If ignore_failure
+ is set to True, ignore the error and return False
+ """
+ from pip._internal.req.constructors import install_req_from_line
+
+ # Ensure that we are looking in the right places for packages. This
+ # is required in CI because pip installs in an area that is not in
+ # the search path.
+ venv_site_lib = str(Path(virtualenv_manager.bin_path, "..", "lib").resolve())
+ venv_site_packages = str(
+ Path(
+ venv_site_lib,
+ f"python{sys.version_info.major}.{sys.version_info.minor}",
+ "site-packages",
+ )
+ )
+ if venv_site_packages not in sys.path and ON_TRY:
+ sys.path.insert(0, venv_site_packages)
+
+ req = install_req_from_line(package)
+ req.check_if_exists(use_user_site=False)
+ # already installed, check if it's in our venv
+ if req.satisfied_by is not None:
+ site_packages = os.path.abspath(req.satisfied_by.location)
+ if site_packages.startswith(venv_site_lib):
+ # already installed in this venv, we can skip
+ return True
+ with silence():
+ try:
+ subprocess.check_call(
+ [virtualenv_manager.python_path, "-m", "pip", "install", package]
+ )
+ return True
+ except Exception:
+ if not ignore_failure:
+ raise
+ return False
+
+
+# on try, we create tests packages where tests, like
+# xpcshell tests, don't have the same path.
+# see - python/mozbuild/mozbuild/action/test_archive.py
+# this mapping will map paths when running there.
+# The key is the source path, and the value the ci path
+_TRY_MAPPING = {Path("netwerk"): Path("xpcshell", "tests", "netwerk")}
+
+
+def build_test_list(tests):
+ """Collects tests given a list of directories, files and URLs.
+
+ Returns a tuple containing the list of tests found and a temp dir for tests
+ that were downloaded from an URL.
+ """
+ temp_dir = None
+
+ if isinstance(tests, str):
+ tests = [tests]
+ res = []
+ for test in tests:
+ if test.startswith("http"):
+ if temp_dir is None:
+ temp_dir = tempfile.mkdtemp()
+ target = Path(temp_dir, test.split("/")[-1])
+ download_file(test, target)
+ res.append(str(target))
+ continue
+
+ p_test = Path(test)
+ if ON_TRY and not p_test.resolve().exists():
+ # until we have pathlib.Path.is_relative_to() (3.9)
+ for src_path, ci_path in _TRY_MAPPING.items():
+ src_path, ci_path = str(src_path), str(ci_path)
+ if test.startswith(src_path):
+ p_test = Path(test.replace(src_path, ci_path))
+ break
+
+ test = p_test.resolve()
+
+ if test.is_file():
+ res.append(str(test))
+ elif test.is_dir():
+ for file in test.rglob("perftest_*.js"):
+ res.append(str(file))
+ else:
+ raise FileNotFoundError(str(test))
+ res.sort()
+ return res, temp_dir
+
+
+def download_file(url, target, retry_sleep=RETRY_SLEEP, attempts=3):
+ """Downloads a file, given an URL in the target path.
+
+ The function will attempt several times on failures.
+ """
+
+ def _download_file(url, target):
+ req = requests.get(url, stream=True, timeout=30)
+ target_dir = target.parent.resolve()
+ if str(target_dir) != "":
+ target_dir.mkdir(exist_ok=True)
+
+ with target.open("wb") as f:
+ for chunk in req.iter_content(chunk_size=1024):
+ if not chunk:
+ continue
+ f.write(chunk)
+ f.flush()
+ return target
+
+ return retry(
+ _download_file,
+ args=(url, target),
+ attempts=attempts,
+ sleeptime=retry_sleep,
+ jitter=0,
+ )
+
+
+@contextlib.contextmanager
+def temporary_env(**env):
+ old = {}
+ for key, value in env.items():
+ old[key] = os.environ.get(key)
+ if value is None and key in os.environ:
+ del os.environ[key]
+ elif value is not None:
+ os.environ[key] = value
+ try:
+ yield
+ finally:
+ for key, value in old.items():
+ if value is None and key in os.environ:
+ del os.environ[key]
+ elif value is not None:
+ os.environ[key] = value
+
+
+def convert_day(day):
+ if day in ("yesterday", "today"):
+ curr = date.today()
+ if day == "yesterday":
+ curr = curr - timedelta(1)
+ day = curr.strftime("%Y.%m.%d")
+ else:
+ # verify that the user provided string is in the expected format
+ # if it can't parse it, it'll raise a value error
+ datetime.strptime(day, "%Y.%m.%d")
+
+ return day
+
+
+def get_revision_namespace_url(route, day="yesterday"):
+ """Builds a URL to obtain all the namespaces of a given build route for a single day."""
+ day = convert_day(day)
+ return f"""{MULTI_REVISION_ROOT}/{route}.{day}.revision"""
+
+
+def get_multi_tasks_url(route, revision, day="yesterday"):
+ """Builds a URL to obtain all the tasks of a given build route for a single day.
+
+ If previous is true, then we get builds from the previous day,
+ otherwise, we look at the current day.
+ """
+ day = convert_day(day)
+ return f"""{MULTI_TASK_ROOT}/{route}.{day}.revision.{revision}"""
+
+
+def strtobool(val):
+ if isinstance(val, (bool, int)):
+ return bool(val)
+ if not isinstance(bool, str):
+ raise ValueError(val)
+ val = val.lower()
+ if val in ("y", "yes", "t", "true", "on", "1"):
+ return 1
+ elif val in ("n", "no", "f", "false", "off", "0"):
+ return 0
+ else:
+ raise ValueError("invalid truth value %r" % (val,))
+
+
+@contextlib.contextmanager
+def temp_dir():
+ tempdir = tempfile.mkdtemp()
+ try:
+ yield tempdir
+ finally:
+ shutil.rmtree(tempdir)
+
+
+def load_class(path):
+ """Loads a class given its path and returns it.
+
+ The path is a string of the form `package.module:class` that points
+ to the class to be imported.
+
+ If if can't find it, or if the path is malformed,
+ an ImportError is raised.
+ """
+ if ":" not in path:
+ raise ImportError(f"Malformed path '{path}'")
+ elmts = path.split(":")
+ if len(elmts) != 2:
+ raise ImportError(f"Malformed path '{path}'")
+ mod_name, klass_name = elmts
+ try:
+ mod = importlib.import_module(mod_name)
+ except ModuleNotFoundError:
+ raise ImportError(f"Can't find '{mod_name}'")
+ try:
+ klass = getattr(mod, klass_name)
+ except AttributeError:
+ raise ImportError(f"Can't find '{klass_name}' in '{mod_name}'")
+ return klass
+
+
+def run_script(cmd, cmd_args=None, verbose=False, display=False, label=None):
+ """Used to run a command in a subprocess."""
+ if isinstance(cmd, str):
+ cmd = shlex.split(cmd)
+ try:
+ joiner = shlex.join
+ except AttributeError:
+ # Python < 3.8
+ joiner = subprocess.list2cmdline
+
+ if label is None:
+ label = joiner(cmd)
+ sys.stdout.write(f"=> {label} ")
+ if cmd_args is None:
+ args = cmd
+ else:
+ args = cmd + list(cmd_args)
+ sys.stdout.flush()
+ try:
+ if verbose:
+ sys.stdout.write(f"\nRunning {' '.join(args)}\n")
+ sys.stdout.flush()
+ output = subprocess.check_output(args)
+ if display:
+ sys.stdout.write("\n")
+ for line in output.split(b"\n"):
+ sys.stdout.write(line.decode("utf8") + "\n")
+ sys.stdout.write("[OK]\n")
+ sys.stdout.flush()
+ return True, output
+ except subprocess.CalledProcessError as e:
+ for line in e.output.split(b"\n"):
+ sys.stdout.write(line.decode("utf8") + "\n")
+ sys.stdout.write("[FAILED]\n")
+ sys.stdout.flush()
+ return False, e
+
+
+def run_python_script(
+ virtualenv_manager,
+ module,
+ module_args=None,
+ verbose=False,
+ display=False,
+ label=None,
+):
+ """Used to run a Python script in isolation."""
+ if label is None:
+ label = module
+ cmd = [virtualenv_manager.python_path, "-m", module]
+ return run_script(cmd, module_args, verbose=verbose, display=display, label=label)
+
+
+def checkout_script(cmd, cmd_args=None, verbose=False, display=False, label=None):
+ return run_script(cmd, cmd_args, verbose, display, label)[0]
+
+
+def checkout_python_script(
+ virtualenv_manager,
+ module,
+ module_args=None,
+ verbose=False,
+ display=False,
+ label=None,
+):
+ return run_python_script(
+ virtualenv_manager, module, module_args, verbose, display, label
+ )[0]
+
+
+_URL = (
+ "{0}/secrets/v1/secret/project"
+ "{1}releng{1}gecko{1}build{1}level-{2}{1}conditioned-profiles"
+)
+_WPT_URL = "{0}/secrets/v1/secret/project/perftest/gecko/level-{1}/perftest-login"
+_DEFAULT_SERVER = "https://firefox-ci-tc.services.mozilla.com"
+
+
+@functools.lru_cache()
+def get_tc_secret(wpt=False):
+ """Returns the Taskcluster secret.
+
+ Raises an OSError when not running on try
+ """
+ if not ON_TRY:
+ raise OSError("Not running in Taskcluster")
+ session = requests.Session()
+ retry = Retry(total=5, backoff_factor=0.1, status_forcelist=[500, 502, 503, 504])
+ http_adapter = requests.adapters.HTTPAdapter(max_retries=retry)
+ session.mount("https://", http_adapter)
+ session.mount("http://", http_adapter)
+ secrets_url = _URL.format(
+ os.environ.get("TASKCLUSTER_PROXY_URL", _DEFAULT_SERVER),
+ "%2F",
+ os.environ.get("MOZ_SCM_LEVEL", "1"),
+ )
+ if wpt:
+ secrets_url = _WPT_URL.format(
+ os.environ.get("TASKCLUSTER_PROXY_URL", _DEFAULT_SERVER),
+ os.environ.get("MOZ_SCM_LEVEL", "1"),
+ )
+ res = session.get(secrets_url, timeout=DOWNLOAD_TIMEOUT)
+ res.raise_for_status()
+ return res.json()["secret"]
+
+
+def get_output_dir(output, folder=None):
+ if output is None:
+ raise Exception("Output path was not provided.")
+
+ result_dir = Path(output)
+ if folder is not None:
+ result_dir = Path(result_dir, folder)
+
+ result_dir.mkdir(parents=True, exist_ok=True)
+ result_dir = result_dir.resolve()
+
+ return result_dir
+
+
+def create_path(path):
+ if path.exists():
+ return path
+ else:
+ create_path(path.parent)
+ path.mkdir(exist_ok=True)
+ return path