diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..f2b28be --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[TYPECHECK] +ignored-classes=ValDict diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..39f9218 --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,6 @@ +# ISSUES.md + +## type hints + +Python 3.8 doesn't support `dict[str,str]` +Python 3.8 is EOL October 2024 diff --git a/phlop/__init__.py b/phlop/__init__.py index e69de29..b117618 100644 --- a/phlop/__init__.py +++ b/phlop/__init__.py @@ -0,0 +1,5 @@ +# +# +# +# +# diff --git a/phlop/__main__.py b/phlop/__main__.py new file mode 100644 index 0000000..3b78f53 --- /dev/null +++ b/phlop/__main__.py @@ -0,0 +1,17 @@ +# +# +# +# +# + + +available_modules = """Available: + phlop.app + phlop.os + phlop.proc + phlop.reflection + phlop.run + phlop.string + phlop.testing""" + +print(available_modules) diff --git a/phlop/app/__init__.py b/phlop/app/__init__.py index dcebcea..b117618 100644 --- a/phlop/app/__init__.py +++ b/phlop/app/__init__.py @@ -1,84 +1,5 @@ -def decode_bytes(input): - return input.decode("ascii", errors="ignore") - - -def run(cmd, shell=True, capture_output=True, check=False, print_cmd=True, **kwargs): - """ - https://docs.python.org/3/library/subprocess.html - """ - import subprocess - - if print_cmd: - print(f"running: {cmd}") - try: - return subprocess.run( - cmd, shell=shell, capture_output=capture_output, check=check, **kwargs - ) - except subprocess.CalledProcessError as e: # only triggers on failure if check=True - print(f"run failed with error: {e}\n\t{e.stdout}\n\t{e.stderr} ") - raise RuntimeError(decode_bytes(e.stderr)) - - -def run_mp(cmds, N_CORES=None, **kwargs): - """ - spawns N_CORES threads (default=len(cmds)) running commands and waiting for results - https://docs.python.org/3/library/concurrent.futures.html - """ - import concurrent.futures - - if N_CORES is None: - N_CORES = len(cmds) - - with concurrent.futures.ThreadPoolExecutor(max_workers=N_CORES) as executor: - jobs = [executor.submit(run, cmd, **kwargs) for cmd in cmds] - results = [] - for future in concurrent.futures.as_completed(jobs): - try: - results += [future.result()] - if future.exception() is not None: - raise future.exception() - except Exception as exc: - if kwargs.get("check", False): - executor.shutdown(wait=False, cancel_futures=True) - raise exc - else: - print(f"run_mp generated an exception: {exc}") - return results - - -def binary_exists_on_path(bin): - """ - https://linux.die.net/man/1/which - """ - return run(f"which {bin}").returncode == 0 - - -def scan_dir(path, files_only=False, dirs_only=False, drop=[]): - import os - - assert os.path.exists(path) - checks = [ - lambda entry: not files_only or (files_only and entry.is_file()), - lambda entry: not dirs_only or (dirs_only and entry.is_dir()), - lambda entry: entry.name not in drop, - ] - return [ - entry.name - for entry in os.scandir(path) - if all([check(entry) for check in checks]) - ] - - -import contextlib - - -@contextlib.contextmanager -def pushd(new_cwd): - import os - - cwd = os.getcwd() - os.chdir(new_cwd) - try: - yield - finally: - os.chdir(cwd) +# +# +# +# +# diff --git a/phlop/app/__main__.py b/phlop/app/__main__.py new file mode 100644 index 0000000..c7cc76d --- /dev/null +++ b/phlop/app/__main__.py @@ -0,0 +1,14 @@ +# +# +# +# +# + + +available_modules = """Available: + phlop.app.cmake + phlop.app.test_cases + phlop.app.git + phlop.app.perf""" + +print(available_modules) diff --git a/phlop/app/cmake.py b/phlop/app/cmake.py new file mode 100644 index 0000000..a59f352 --- /dev/null +++ b/phlop/app/cmake.py @@ -0,0 +1,104 @@ +# +# +# +# +# + + +import json +from dataclasses import dataclass, field + +from phlop.proc import run +from phlop.string import decode_bytes + + +def version(): + pass + + +def make_config_str(path, cxx_flags=None, use_ninja=False, use_ccache=False, extra=""): + cxx_flags = "" if cxx_flags is None else f'-DCMAKE_CXX_FLAGS="{cxx_flags}"' + ccache = "" if use_ccache is False else "-DCMAKE_CXX_COMPILER_LAUNCHER=ccache" + ninja = "" if not use_ninja else "-G Ninja" + + return f"cmake {path} {cxx_flags} {ninja} {ccache} {extra}" + + +def config(path, cxx_flags=None, use_ninja=False, use_ccache=False, extra=""): + cmd = make_config_str(path, cxx_flags, use_ninja, extra) + run(cmd, capture_output=False) + + +def build(use_ninja=False, threads=1): + run("ninja" if use_ninja else f"make -j{threads}", capture_output=False) + + +@dataclass +class CTest_test: + backtrace: int # care? + command: list # list[str] # eventually + # [ + # "/opt/py/py/bin/python3", + # "-u", + # "test_particles_advance_2d.py" + # ], + name: str # "py3_advance-2d-particles", + properties: list = field(default_factory=lambda: [{}]) # list[dict] # eventually + + env: dict = field(default_factory=lambda: {}) # dict[str, str] # eventually + working_dir: str = field(default_factory=lambda: None) + + def __post_init__(self): + for p in self.properties: + if p["name"] == "ENVIRONMENT": + for item in p["value"]: + bits = item.split("=") + self.env.update({bits[0]: "=".join(bits[1:])}) + elif p["name"] == "WORKING_DIRECTORY": + self.working_dir = p["value"] + + # [ + # { + # "name" : "ENVIRONMENT", + # "value" : + # [ + # "PYTHONPATH=/home/p/git/phare/master/build:/home/p/git/phare/master/pyphare", + # "ASAN_OPTIONS=detect_leaks=0" + # ] + # }, + # { + # "name" : "WORKING_DIRECTORY", + # "value" : "/home/p/git/phare/master/build/tests/simulator/advance" + # } + # ] + + +def list_tests(build_dir=None): + cmd = "".join( + [ + s + for s in [ + "ctest ", + f"--test-dir {build_dir} " if build_dir else "", + "--show-only=json-v1", + ] + if s + ] + ) + return [ + CTest_test(**test) + for test in json.loads(decode_bytes(run(cmd, capture_output=True).stdout))[ + "tests" + ] + ] + + +def test_cmd(test, verbose=False): + cmd = f"ctest -R {test}" + if verbose: + cmd = f"{cmd} -V" + return cmd + + +if __name__ == "__main__": + list_tests("build") diff --git a/phlop/app/git.py b/phlop/app/git.py index 87ac804..4fcc362 100644 --- a/phlop/app/git.py +++ b/phlop/app/git.py @@ -1,3 +1,10 @@ +# +# +# +# +# + + import atexit import subprocess @@ -26,11 +33,7 @@ def branch_exists(branch): return True -def checkout(branch, create=False, recreate=False): - if recreate: - delete_branch(branch) - create = True - +def checkout(branch, create=False): if create and not branch_exists(branch): run(f"git checkout -b {branch}", check=True) else: diff --git a/phlop/app/perf.py b/phlop/app/perf.py index b9a084d..b491fa8 100644 --- a/phlop/app/perf.py +++ b/phlop/app/perf.py @@ -1,3 +1,10 @@ +# +# +# +# +# + + import csv import os @@ -6,10 +13,10 @@ def version(): # validated on perf version: 5.19 - proc = run("perf -v", shell=True, capture_output=True) + proc = run("perf -v", shell=True, capture_output=True).out() if " " not in proc or "." not in proc: raise ValueError("Unparsable result from 'perf -v'") - return [int(digit) for digit in proc.split(" ").split(".")] + return [int(digit) for digit in proc.split(" ")[-1].split(".")] def check(force_kernel_space=False): diff --git a/phlop/app/sleep.py b/phlop/app/sleep.py new file mode 100644 index 0000000..df423d0 --- /dev/null +++ b/phlop/app/sleep.py @@ -0,0 +1,14 @@ +# +# +# +# +# + +import sys +import time + +if __name__ == "__main__": + t = sys.argv[1] + if not t: + t = 1 + time.sleep(int(t)) diff --git a/phlop/app/stats_man.py b/phlop/app/stats_man.py deleted file mode 100644 index 24e6962..0000000 --- a/phlop/app/stats_man.py +++ /dev/null @@ -1,96 +0,0 @@ -import os -import signal -import sys -import threading -import time -from datetime import datetime -from multiprocessing import Process -from pathlib import Path - -import psutil -import yaml - -SCRIPT_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) -ROOT_DIRECTORY = Path(SCRIPT_DIRECTORY).parent.parent - -config = dict( - dir=ROOT_DIRECTORY / ".runtime_log", - interval=2, # seconds -) - -_globals = dict() - - -def check_pid(pid): - """Check For the existence of a unix pid.""" - try: - os.kill(pid, 0) - except OSError: - return False - else: - return True - - -def cleanup(): - output = config["dir"] - output.mkdir(parents=True, exist_ok=True) - - with open(output / f"stats.{_globals['pid']}.yaml", "w") as file: - file.write(yaml.dump(_globals["captures"])) - - -def signal_handler(sig, frame): - _globals["sub_run"] = False - cleanup() - _globals.clear() - sys.exit(0) - - -def capture_now(pid): - now = datetime.utcnow().timestamp() - process = psutil.Process(pid=pid) - mem = int(process.memory_info().rss / 1024**2) # bytes -> MB # drop decimals - fds = len(process.open_files()) - return dict( - mem=mem, - fds=fds, - now=now, - ) - - -class RuntimeStatsManager: - def __init__(self, pid): - self.pid = pid - self.p = Process(target=RuntimeStatsManager._run, args=(pid, os.getpid())) - self.t = threading.Thread(target=RuntimeStatsManager._thread, args=(self,)) - self.p.start() - self.t.start() - - def __del__(self): - pid = self.p.pid - os.kill(pid, signal.SIGINT) - - # hangs if exitcode is not checked? - while self.p.exitcode is None and check_pid(pid): - time.sleep(1) - - @staticmethod - def _thread(self): - while threading.main_thread().is_alive(): - time.sleep(1) - _globals.clear() - - @staticmethod - def _run(pid, host_pid): - _globals["pid"] = pid - _globals["host_pid"] = host_pid - _globals["captures"] = [] - _globals["sub_run"] = True - signal.signal(signal.SIGINT, signal_handler) - while _globals["sub_run"]: - time.sleep(config["interval"]) - _globals["captures"] += [capture_now(pid)] - - -def attach_to_this_process(): - _globals["this_proc"] = RuntimeStatsManager(os.getpid()) diff --git a/phlop/dict.py b/phlop/dict.py new file mode 100644 index 0000000..c251e4e --- /dev/null +++ b/phlop/dict.py @@ -0,0 +1,12 @@ +# +# +# +# +# + + +# pylintrc ignored no-member +class ValDict(dict): + def __init__(self, **d): + for k, v in d.items(): + object.__setattr__(self, k, v) diff --git a/phlop/logger.py b/phlop/logger.py new file mode 100644 index 0000000..d4f1550 --- /dev/null +++ b/phlop/logger.py @@ -0,0 +1,10 @@ +# +# +# +# + + +import logging + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) diff --git a/phlop/os.py b/phlop/os.py index 248deae..a0f6c15 100644 --- a/phlop/os.py +++ b/phlop/os.py @@ -1,5 +1,13 @@ +# +# +# +# +# + + import contextlib import os +from pathlib import Path def scan_dir(path, files_only=False, dirs_only=False, drop=[]): @@ -18,7 +26,8 @@ def scan_dir(path, files_only=False, dirs_only=False, drop=[]): @contextlib.contextmanager def pushd(new_cwd): - import os + if not os.path.exists(new_cwd): + raise RuntimeError("phlop.os.pushd: new_cwd does not exist") cwd = os.getcwd() os.chdir(new_cwd) @@ -26,3 +35,13 @@ def pushd(new_cwd): yield finally: os.chdir(cwd) + + +def write_to_file(file, contents, mode="w", skip_if_empty=True): + if contents or not skip_if_empty: + try: + Path(file).parent.mkdir(parents=True, exist_ok=True) + with open(file, mode) as f: + f.write(contents) + except IOError as e: + raise RuntimeError(f"Failed to write to file {file}: {e}") diff --git a/phlop/proc.py b/phlop/proc.py index 402e0cb..77dece4 100644 --- a/phlop/proc.py +++ b/phlop/proc.py @@ -1,46 +1,16 @@ +# +# +# +# +# + import subprocess -import time +from phlop.os import pushd, write_to_file +from phlop.procs.runtimer import RunTimer from phlop.string import decode_bytes -class RunTimer: - def __init__( - self, - cmd, - shell=True, - capture_output=True, - check=False, - print_cmd=True, - **kwargs, - ): - self.cmd = cmd - start = time.time() - - self.run_time = time.time() - start - self.stdout = "" - - try: - self.run = subprocess.run( - self.cmd, - shell=shell, - capture_output=capture_output, - check=check, - **kwargs, - ) - self.run_time = time.time() - start - self.stdout = self.run.stdout - self.stderr = self.run.stderr - self.exitcode = self.run.returncode - except ( - subprocess.CalledProcessError - ) as e: # only triggers on failure if check=True - self.exitcode = e.returncode - self.stdout = decode_bytes(e.stdout) - self.stderr = decode_bytes(e.stderr) - self.run_time = time.time() - start - - class ProcessNonZeroExitCode(RuntimeError): ... @@ -55,6 +25,13 @@ def run(cmd, shell=True, capture_output=True, check=False, print_cmd=True, **kwa ) +def run_raw(args: list, shell=False, quiet=False): + pipe = subprocess.DEVNULL if quiet else None + return subprocess.Popen( + args, shell=shell, stdin=pipe, stdout=pipe, stderr=pipe, close_fds=True + ) + + def run_mp(cmds, N_CORES=None, **kwargs): """ spawns N_CORES threads (default=len(cmds)) running commands and waiting for results @@ -87,4 +64,4 @@ def binary_exists_on_path(bin): https://linux.die.net/man/1/which """ raise ValueError("do better") - return run(f"which {bin}").returncode == 0 + return run(f"which {bin}").exitcode == 0 diff --git a/phlop/procs/__init__.py b/phlop/procs/__init__.py new file mode 100644 index 0000000..b117618 --- /dev/null +++ b/phlop/procs/__init__.py @@ -0,0 +1,5 @@ +# +# +# +# +# diff --git a/phlop/procs/__main__.py b/phlop/procs/__main__.py new file mode 100644 index 0000000..683ec94 --- /dev/null +++ b/phlop/procs/__main__.py @@ -0,0 +1,11 @@ +# +# +# +# +# + + +available_modules = """Available: + phlop.procs.runtimer""" + +print(available_modules) diff --git a/phlop/procs/runtimer.py b/phlop/procs/runtimer.py new file mode 100644 index 0000000..b213fb1 --- /dev/null +++ b/phlop/procs/runtimer.py @@ -0,0 +1,83 @@ +# +# +# +# +# + + +import os +import subprocess +import time + +from phlop.os import pushd, write_to_file +from phlop.string import decode_bytes + + +class RunTimer: + def __init__( + self, + cmd, + shell=True, + capture_output=True, + check=False, + print_cmd=True, + env: dict = {}, # dict[str, str] # eventually + working_dir=None, + log_file_path=None, + **kwargs, + ): + self.cmd = cmd + start = time.time() + + self.run_time = None + self.stdout = "" + benv = os.environ.copy() + benv.update(env) + + ekwargs = {} + if not capture_output and log_file_path: + ekwargs.update( + dict( + stdout=open(f"{log_file_path}.stdout", "w"), + stderr=open(f"{log_file_path}.stderr", "w"), + ), + ) + + def run(): + try: + self.run = subprocess.run( + self.cmd, + shell=shell, + check=check, + env=benv, + capture_output=capture_output, + **kwargs, + **ekwargs, + ) + self.run_time = time.time() - start + self.exitcode = self.run.returncode + if capture_output: + self.stdout = decode_bytes(self.run.stdout) + self.stderr = decode_bytes(self.run.stderr) + except ( + subprocess.CalledProcessError + ) as e: # only triggers on failure if check=True + self.exitcode = e.returncode + self.run_time = time.time() - start + if capture_output: + self.stdout = decode_bytes(e.stdout) + self.stderr = decode_bytes(e.stderr) + if capture_output and log_file_path: + write_to_file(f"{log_file_path}.stdout", self.stdout) + write_to_file(f"{log_file_path}.stderr", self.stderr) + + if working_dir: + with pushd(working_dir): + run() + else: + run() + + def out(self, ignore_exit_code=False): + if not ignore_exit_code and self.exitcode > 0: + raise RuntimeError(f"phlop.RunTimer error: {self.stderr}") + return self.stdout diff --git a/phlop/reflection.py b/phlop/reflection.py index 5d939b5..8f7d1fa 100644 --- a/phlop/reflection.py +++ b/phlop/reflection.py @@ -1,24 +1,41 @@ +# +# +# +# +# + import importlib import inspect +import logging import os from pathlib import Path +from phlop.sys import extend_sys_path + -def classes_in_file(file, subclasses_only=None): +def classes_in_file(file, subclasses_only=None, fail_on_import_error=True): module = str(file).replace(os.path.sep, ".")[:-3] if subclasses_only is not None and not isinstance(subclasses_only, list): subclasses_only = [subclasses_only] classes = [] - for name, cls in inspect.getmembers( - importlib.import_module(module), inspect.isclass - ): - should_add = subclasses_only == None or any( - [issubclass(cls, sub) for sub in subclasses_only] - ) - if should_add: - classes += [cls] + + with extend_sys_path(os.getcwd()): + try: + for name, cls in inspect.getmembers( + importlib.import_module(module), inspect.isclass + ): + should_add = subclasses_only == None or any( + [issubclass(cls, sub) for sub in subclasses_only] + ) + if should_add: + classes += [cls] + except (ValueError, ModuleNotFoundError) as e: + if fail_on_import_error: + raise e + logging.error(f"Skipping on error: {e} in module {module}") + return classes diff --git a/phlop/run/__main__.py b/phlop/run/__main__.py new file mode 100644 index 0000000..80552b0 --- /dev/null +++ b/phlop/run/__main__.py @@ -0,0 +1,12 @@ +# +# +# +# +# + + +available_modules = """Available: + phlop.run.test_cases -h + phlop.run.stats_man -h""" + +print(available_modules) diff --git a/phlop/run/stats_man.py b/phlop/run/stats_man.py new file mode 100644 index 0000000..8b193b2 --- /dev/null +++ b/phlop/run/stats_man.py @@ -0,0 +1,160 @@ +# +# +# +# +# + + +import logging +import os +import signal +import sys +import time +from dataclasses import dataclass, field +from datetime import datetime +from multiprocessing import Process, Queue + +import numpy as np +import psutil +import yaml + +from phlop.dict import ValDict +from phlop.proc import run_raw + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +_default_interval = 2 + + +@dataclass +class ProcessCaptureInfo: + cpu_load: list = field(default_factory=lambda: []) + fds: list = field(default_factory=lambda: []) + mem_usage: list = field(default_factory=lambda: []) + timestamps: list = field(default_factory=lambda: []) + + +def cli_args_parser(): + import argparse + + _help = ValDict( + quiet="Redirect output to /dev/null", + interval="Seconds between process stat capture", + ) + parser = argparse.ArgumentParser() + parser.add_argument("remaining", nargs=argparse.REMAINDER) + parser.add_argument( + "-q", "--quiet", action="store_true", default=False, help=_help.quiet + ) + parser.add_argument( + "-i", "--interval", default=_default_interval, help=_help.interval + ) + return parser + + +def verify_cli_args(cli_args): + try: + cli_args.interval = int(cli_args.interval) + except ValueError: + raise ValueError("Interval must be an integer") + return cli_args + + +def check_pid(pid): + """Check For the existence of a unix pid.""" + if not pid: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def signal_handler(sig, frame): + sys.exit(0) + + +def bytes_as_mb(n_bytes): + return int(n_bytes / 1024**2) + + +def capture_now(pid, data): + now = datetime.utcnow().timestamp() + proc = psutil.Process(pid=pid) + data.cpu_load += [proc.cpu_percent(interval=0.1)] + data.fds += [len(proc.open_files())] + data.mem_usage += [bytes_as_mb(proc.memory_info().rss)] + data.timestamps += [now] + + +class RuntimeStatsManager: + def __init__(self, pid, interval=_default_interval): + self.pid = pid + self.interval = interval + self.pqueue = Queue() + self.data = {} + self.p = Process(target=RuntimeStatsManager._run, args=(self,)) + self.p.start() + self.data = self.pqueue.get() + + def __del__(self): + if check_pid(self.p.pid): + os.kill(self.p.pid, signal.SIGINT) + self.join() + + def join(self): + if not self.pid: + return + while self.p.exitcode is None and check_pid(self.p.pid): + time.sleep(1) + self.pid = 0 + return self + + @staticmethod + def _run(this): + data = ProcessCaptureInfo() + signal.signal(signal.SIGINT, signal_handler) + while check_pid(this.pid): + try: + capture_now(this.pid, data) + except psutil.AccessDenied: + break + time.sleep(this.interval) + this.pqueue.put(data) + + +def attach_to_this_process(): + return RuntimeStatsManager(os.getpid()) + + +def print_summary(statsman): + print("summary:") + print("\tCPU avg:", np.average(statsman.data.cpu_load)) + print("\tCPU max:", np.max(statsman.data.cpu_load)) + + print("\tFDS avg:", np.average(statsman.data.fds)) + print("\tFDS max:", np.max(statsman.data.fds)) + + print("\tMEM avg:", np.average(statsman.data.mem_usage)) + print("\tMEM max:", np.max(statsman.data.mem_usage)) + + +def main(): + parser = cli_args_parser() + cli_args = verify_cli_args(parser.parse_args()) + try: + proc = run_raw(cli_args.remaining, quiet=cli_args.quiet) + statsman = RuntimeStatsManager(proc.pid, cli_args.interval).join() + print_summary(statsman) + except (Exception, SystemExit) as e: + logger.exception(e) + parser.print_help() + except: + e = sys.exc_info()[0] + print(f"Error: Unknown Error {e}") + + +if __name__ == "__main__": + main() diff --git a/phlop/run/test_cases.py b/phlop/run/test_cases.py index b8147ac..20e8568 100644 --- a/phlop/run/test_cases.py +++ b/phlop/run/test_cases.py @@ -1,44 +1,90 @@ +# +# +# +# +# + + +import logging +import multiprocessing +import sys import unittest -from distutils.util import strtobool +from pathlib import Path +from phlop.dict import ValDict from phlop.reflection import classes_in_directory -from phlop.testing.parallel_processor import load_test_cases_in, process +from phlop.testing import parallel_processor as pp + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) -def parse_cli_args(): +def cli_args_parser(): import argparse + _help = ValDict( + dir="Working directory", + cmake="Enable cmake build config tests extraction", + cores="Parallism core/thread count", + print_only="Print only, no execution", + prefix="Prepend string to execution string", + postfix="Append string to execution string", + ) + parser = argparse.ArgumentParser() - parser.add_argument("-d", "--dir", help="", required=True) + parser.add_argument("--cmake", action="store_true", default=False, help=_help.cmake) + parser.add_argument("-d", "--dir", default=".", help=_help.dir) parser.add_argument("-r", "--retries", type=int, default=0, help="") - parser.add_argument("-c", "--cores", type=int, default=1, help="") + parser.add_argument("-c", "--cores", type=int, default=1, help=_help.cores) + parser.add_argument("-f", "--filter", type=str, default="", help="") parser.add_argument( - "-p", "--print_only", action="store_true", default=False, help="" + "-p", "--print_only", action="store_true", default=False, help=_help.print_only ) - - return parser.parse_args() + parser.add_argument("--prefix", default="", help=_help.prefix) + parser.add_argument("--postfix", default="", help=_help.postfix) + return parser def verify_cli_args(cli_args): if cli_args.cores == "a" or cli_args.cores == "all": - cli_args.cores = cpu_count() + cli_args.cores = multiprocessing.cpu_count() cli_args.cores = int(cli_args.cores) - + if not Path(cli_args.dir).exists(): + raise RuntimeError( + "phlop.run.test_cases error: directory provided does not exist" + ) return cli_args -def get_test_classes(cli_args): - return load_test_cases_in(classes_in_directory(cli_args.dir, unittest.TestCase)) +def get_test_cases(cli_args): + if cli_args.cmake: + return pp.load_cmake_tests( + cli_args.dir, test_cmd_pre=cli_args.prefix, test_cmd_post=cli_args.postfix + ) + return pp.TestBatch( + pp.load_test_cases_in( + classes_in_directory(cli_args.dir, unittest.TestCase), + test_cmd_pre=cli_args.prefix, + test_cmd_post=cli_args.postfix, + ) + ) def main(): - cli_args = verify_cli_args(parse_cli_args()) - - process( - get_test_classes(cli_args), - n_cores=cli_args.cores, - print_only=cli_args.print_only, - ) + parser = cli_args_parser() + cli_args = verify_cli_args(parser.parse_args()) + try: + pp.process( + get_test_cases(cli_args), + n_cores=cli_args.cores, + print_only=cli_args.print_only, + ) + except (Exception, SystemExit) as e: + logger.exception(e) + parser.print_help() + except: + e = sys.exc_info()[0] + print(f"Error: Unknown Error {e}") if __name__ == "__main__": diff --git a/phlop/string.py b/phlop/string.py index 06a2772..3ebf44b 100644 --- a/phlop/string.py +++ b/phlop/string.py @@ -1,2 +1,13 @@ +# +# +# +# +# + + def decode_bytes(input, as_format="utf-8"): - return input.decode("ascii", errors="ignore") + if isinstance(input, str): + return input + if isinstance(input, bytes): + return input.decode(as_format, errors="ignore") + raise ValueError(f"phlop.string::decode_bytes unknown input type: {type(input)}") diff --git a/phlop/sys.py b/phlop/sys.py new file mode 100644 index 0000000..5501616 --- /dev/null +++ b/phlop/sys.py @@ -0,0 +1,21 @@ +# +# +# +# +# + + +import sys +from contextlib import contextmanager + + +@contextmanager +def extend_sys_path(paths): + if isinstance(paths, str): + paths = [paths] + old_path = sys.path[:] + sys.path.extend(paths) + try: + yield + finally: + sys.path = old_path diff --git a/phlop/testing/__main__.py b/phlop/testing/__main__.py new file mode 100644 index 0000000..f019650 --- /dev/null +++ b/phlop/testing/__main__.py @@ -0,0 +1,12 @@ +# +# +# +# +# + + +available_modules = """Available: + phlop.testing.parallel_processor + phlop.testing_test_cases""" + +print(available_modules) diff --git a/phlop/testing/parallel_processor.py b/phlop/testing/parallel_processor.py index ad1925d..e350766 100644 --- a/phlop/testing/parallel_processor.py +++ b/phlop/testing/parallel_processor.py @@ -1,48 +1,37 @@ +# +# +# +# +# + + import time -import unittest from multiprocessing import Process, Queue, cpu_count from phlop.proc import ProcessNonZeroExitCode, run +from phlop.testing.test_cases import * class TestCaseFailure(Exception): ... -def test_cmd(clazz, test_id, cores): - return f"python3 -m {clazz.__module__} {clazz.__name__}.{test_id}" - - -class TestBatch: - def __init__(self, tests, cores=1): - self.tests = tests - self.cores = cores - - -def load_test_cases_in(classes, cores=1, test_cmd_fn=None): - test_cmd_fn = test_cmd_fn if test_cmd_fn else test_cmd - - tests, loader = [], unittest.TestLoader() - for test_class in classes: - for suite in loader.loadTestsFromTestCase(test_class): - tests += [test_cmd_fn(type(suite), suite._testMethodName, cores)] - - return TestBatch(tests, cores) - - class CallableTest: - def __init__(self, batch_index, cmd): + def __init__(self, batch_index, test_case): self.batch_index = batch_index - self.cmd = cmd + self.test_case = test_case self.run = None def __call__(self, **kwargs): self.run = run( - self.cmd.split(), + self.test_case.cmd.split(), shell=False, capture_output=True, check=True, print_cmd=False, + env=self.test_case.env, + working_dir=self.test_case.working_dir, + log_file_path=self.test_case.log_file_path, ) if self.run.exitcode != 0: print(self.run.stderr) @@ -52,7 +41,7 @@ def __call__(self, **kwargs): class CoreCount: def __init__(self, cores_avail): self.cores_avail = cores_avail - self.proces = [] + self.procs = [] self.fin = [] @@ -63,13 +52,14 @@ def runner(runnable, queue): def print_tests(batches): for batch in batches: for test in batch.tests: - print(test) + print(test.cmd) def process(batches, n_cores=None, print_only=False, fail_fast=False): if not isinstance(batches, list): batches = [batches] - + if sum([len(t.tests) for t in batches]) == 0: + return # nothing to do if print_only: print_tests(batches) return @@ -95,10 +85,7 @@ def launch_tests(): cc.procs[batch_index] += [ Process( target=runner, - args=( - test, - (pqueue), - ), + args=(test, (pqueue)), ) ] cc.procs[batch_index][-1].daemon = True @@ -120,7 +107,9 @@ def waiter(queue): fail += proc.run.exitcode if fail_fast and fail > 0: raise TestCaseFailure("Some tests have failed") - print(proc.cmd, f"{status} in {proc.run.run_time:.2f} seconds") + print( + proc.test_case.cmd, f"{status} in {proc.run.run_time:.2f} seconds" + ) cc.cores_avail += batches[proc.batch_index].cores cc.fin[proc.batch_index] += 1 launch_tests() diff --git a/phlop/testing/test_cases.py b/phlop/testing/test_cases.py new file mode 100644 index 0000000..b9da4ce --- /dev/null +++ b/phlop/testing/test_cases.py @@ -0,0 +1,140 @@ +# +# +# +# +# + + +import os +import unittest +from dataclasses import dataclass, field +from pathlib import Path + +from phlop.app.cmake import list_tests as get_cmake_tests +from phlop.os import pushd +from phlop.proc import run +from phlop.reflection import classes_in_file + +_LOG_DIR = Path(os.environ.get("PHLOP_LOG_DIR", os.getcwd())) + + +@dataclass +class TestCase: + cmd: str + env: dict = field(default_factory=lambda: {}) # dict[str, str] # eventually + working_dir: str = field(default_factory=lambda: None) + log_file_path: str = field(default_factory=lambda: None) + + +class TestBatch: + def __init__(self, tests, cores=1): + self.tests = tests + self.cores = cores + + +class DefaultTestCaseExtractor: + def __call__(self, ctest_test): + return [ + TestCase( + cmd=ctest_test.cmd, + env=ctest_test.env, + working_dir=ctest_test.working_dir, + log_file_path=_LOG_DIR + / ".phlop" + / f"{Path(ctest_test.working_dir).relative_to(_LOG_DIR)}", + ) + ] + + +class GoogleTestCaseExtractor: + def __call__(self, ctest_test): + ... + # not configured, assumed fast per file + # print("GoogleTestCaseExtractor") + # exec binary with `--gtest_list_tests` and see if it doesn't fail + # p = run( + # ctest_test.cmd + " --gtest_list_tests --gtest_output=json:gtest.json", + # working_dir=ctest_test.working_dir, + # ) + # print(p.stdout) + + return None + + +class PythonUnitTestCaseExtractor: + def __call__(self, ctest_test): + if "python3" in ctest_test.cmd: # hacky + return load_test_cases_from_cmake(ctest_test) + return None + + +EXTRACTORS = [ + PythonUnitTestCaseExtractor(), + GoogleTestCaseExtractor(), + DefaultTestCaseExtractor(), +] + + +def python3_default_test_cmd(clazz, test_id): + return f"python3 -m {clazz.__module__} {clazz.__name__}.{test_id}" + + +def load_test_cases_in( + classes, test_cmd_pre="", test_cmd_post="", test_cmd_fn=None, **kwargs +): + test_cmd_fn = test_cmd_fn if test_cmd_fn else python3_default_test_cmd + log_file_path = kwargs.pop("log_file_path", None) + tests, loader = [], unittest.TestLoader() + for test_class in classes: + for suite in loader.loadTestsFromTestCase(test_class): + cmd = test_cmd_fn(type(suite), suite._testMethodName) + tests += [ + TestCase( + cmd=f"{test_cmd_pre} {cmd} {test_cmd_post}".strip(), + log_file_path=None + if not log_file_path + else f"{log_file_path}/{suite._testMethodName}", + **kwargs, + ) + ] + return tests + + +def load_test_cases_from_cmake(ctest_test): + ppath = f"{ctest_test.working_dir}:{ctest_test.env.get('PYTHONPATH','')}" + ctest_test.env["PYTHONPATH"] = ppath + with pushd(ctest_test.working_dir): + print("ctest_test", ctest_test.cmd) + pyfile = ctest_test.cmd.split(" ")[-1] + return load_test_cases_in( + classes_in_file(pyfile, unittest.TestCase, fail_on_import_error=False), + env=ctest_test.env, + working_dir=ctest_test.working_dir, + log_file_path=_LOG_DIR + / ".phlop" + / f"{Path(ctest_test.working_dir).relative_to(_LOG_DIR)}", + ) + + +# probably return a list of TestBatch if we do some core count detection per test +def load_cmake_tests(cmake_dir, cores=1, test_cmd_pre="", test_cmd_post=""): + cmake_tests = get_cmake_tests(cmake_dir) + tests = [] + for cmake_test in cmake_tests: + tests += [ + TestCase( + cmd=test_cmd_pre + " ".join(cmake_test.command) + " " + test_cmd_post, + env=cmake_test.env, + working_dir=cmake_test.working_dir, + ) + ] + + test_cases = [] + for test in tests: + for extractor in EXTRACTORS: + res = extractor(test) + if res: + test_cases += res + break + + return TestBatch(test_cases, cores) diff --git a/pyproject.toml b/pyproject.toml index 4fe1d0f..cb137be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "phlop" -version = "0.0.6" +version = "0.0.7" dependencies = [ diff --git a/setup.py b/setup.py index 95f5d3e..f27a50e 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="phlop", - version="0.0.6", + version="0.0.7", cmdclass={}, classifiers=[], include_package_data=True, diff --git a/sh/lint.sh b/sh/lint.sh index 2c2c47b..1f0ea4b 100755 --- a/sh/lint.sh +++ b/sh/lint.sh @@ -7,8 +7,13 @@ set -e PY_FILES=$(find . -name "*.py") +python3 -m black phlop tests +pylint --errors-only phlop tests +isort phlop tests + for FILE in ${PY_FILES[@]}; do - python3 -m black "$FILE" + autoflake -i "$FILE" - isort "$FILE" + + done diff --git a/tests/all_concurrent.py b/tests/all_concurrent.py index 2db78f5..243caa9 100644 --- a/tests/all_concurrent.py +++ b/tests/all_concurrent.py @@ -10,17 +10,17 @@ from pathlib import Path from phlop.reflection import classes_in_directory -from phlop.testing.parallel_processor import load_test_cases_in, process +from phlop.testing import parallel_processor as pp N_CORES = int(os.environ["N_CORES"]) if "N_CORES" in os.environ else cpu_count() PRINT = bool(json.loads(os.environ.get("PRINT", "false"))) def get_test_classes(): - return load_test_cases_in( - classes_in_directory(str(Path("tests") / "phlop"), unittest.TestCase) + return pp.load_test_cases_in( + classes_in_directory(str(Path("tests")), unittest.TestCase) ) if __name__ == "__main__": - process(get_test_classes(), n_cores=N_CORES, print_only=PRINT) + pp.process(pp.TestBatch(get_test_classes()), n_cores=N_CORES, print_only=PRINT)