diff --git a/.gitignore b/.gitignore index 37bec31ad4e16a0912958f4fede2c304b5005649..0e5ed67a5153d0321ecac4f9517f0f0e896e6223 100644 --- a/.gitignore +++ b/.gitignore @@ -57,5 +57,5 @@ projects_checklist.txt .ipynb_checkpoints deps.png build/pkgs -eggs -parts \ No newline at end of file +**/eggs +**/parts \ No newline at end of file diff --git a/Makefile b/Makefile index 79d404658d21dd65b7f8ba54abcc1c23d91e20be..9aa967a1c4b5a7478695dd3eb770f4101b33897d 100644 --- a/Makefile +++ b/Makefile @@ -15,3 +15,7 @@ metadata: .PHONY: build build: buildout parts=build_pkgs name=all + +.PHONY: test +test: + buildout parts=test name=all \ No newline at end of file diff --git a/apps/cli/executables/null/setup.py b/apps/cli/executables/null/setup.py index 91b6b575c50c3c08f10dbc16446d91301d71d40c..c7a5dcaf94e82b73b2284a472f99e3d09c767d2c 100644 --- a/apps/cli/executables/null/setup.py +++ b/apps/cli/executables/null/setup.py @@ -7,9 +7,6 @@ from setuptools import setup VERSION = open('src/null/_version.py').readlines()[-1].split()[-1].strip("\"'") README = Path('README.md').read_text() -# requires = [ -# ] - tests_require = [ 'pytest>=5.4,<6.0' ] @@ -22,11 +19,10 @@ setup( author_email='dms-ssa@nrao.edu', url='TBD', license="GPL", - # install_requires=requires, tests_require=tests_require, keywords=[], packages=['null'], - package_dir={'':'src'}, + package_dir={'': 'src'}, classifiers=[ 'Programming Language :: Python :: 3.8' ], diff --git a/apps/cli/executables/null/src/null/null.py b/apps/cli/executables/null/src/null/null.py index 6f806ae60910206b36c8a97d3fb9eca700595529..1cdacad80b9c8e028ce8d0f4ddd6f31bb4dae0e6 100644 --- a/apps/cli/executables/null/src/null/null.py +++ b/apps/cli/executables/null/src/null/null.py @@ -16,47 +16,75 @@ logger = logging.getLogger("null") logger.setLevel(logging.INFO) handler = logging.StreamHandler(stream=sys.stdout) -class Null: - def __init__(self, args, verbose): - self.args = args - if verbose: - logger.setLevel(logging.DEBUG) - self.args_to_funcs = { - 'print-error': self.print_error, - 'greeting': self.print_greeting, - 'exit-fail': self.exit_with_failure, - 'exit-random': self.exit_randomly, - 'nap': self.take_nap, - 'dump': self.dump_core - } - def print_error(self): - logger.removeHandler(handler) - err_handler = logging.StreamHandler(stream=sys.stderr) - logger.addHandler(err_handler) - logger.error("ERROR: This is an error.") +def print_error(): + """ + Logs an error message to stderr. + """ + logger.removeHandler(handler) + err_handler = logging.StreamHandler(stream=sys.stderr) + logger.addHandler(err_handler) + logger.error("ERROR: This is an error.") + + +def print_greeting(): + """ + Prints a friendly greeting to stdout + """ + logger.info("Hello, world!") + logger.debug("And goodbye, world...") + + +def exit_with_failure(): + """ + Exits with status code -1 + """ + logger.error("Error purposefully induced. Exiting with status code -1...") + sys.exit(-1) + + +def exit_randomly(): + """ + Exits with a random status code between -50 and 50 + """ + status_code = random.randint(-50, 50) + logger.debug("Exiting with status code {}".format(status_code)) + sys.exit(status_code) - def print_greeting(self): - logger.info("Hello, world!") - logger.debug("And goodbye, world...") - def exit_with_failure(self): - logger.error("Error purposefully induced. Exiting with status code -1...") - sys.exit(-1) +def take_nap(): + """ + Sleeps for 5 seconds + """ + logger.debug("Going to sleep...") + time.sleep(5) + logger.debug("Waking up.") - def exit_randomly(self): - status_code = random.randint(-50, 50) - logger.debug("Exiting with status code {}".format(status_code)) - sys.exit(status_code) - def take_nap(self): - logger.debug("Going to sleep...") - time.sleep(5) - logger.debug("Waking up.") +def dump_core(): + """ + Makes a call to os.abort() which dumps the core + """ + logger.debug("Aborting and dumping core...", stack_info=True) + os.abort() + - def dump_core(self): - logger.debug("Aborting and dumping core...", stack_info=True) - os.abort() +class Null: + """ + Null executable that executes null functionality based on arguments given + """ + def __init__(self, args: argparse.Namespace, verbose: bool): + self.args = args + if verbose: + logger.setLevel(logging.DEBUG) + self.args_to_funcs = { + 'print-error': print_error, + 'greeting': print_greeting, + 'exit-fail': exit_with_failure, + 'exit-random': exit_randomly, + 'nap': take_nap, + 'dump': dump_core + } def execute(self): """ @@ -66,7 +94,8 @@ class Null: if val and arg in self.args_to_funcs: self.args_to_funcs[arg]() -def make_arg_parser(): + +def make_arg_parser() -> argparse.ArgumentParser: """ Creates an argparse arguments parser with appropriate options :return: Said argument parser @@ -99,6 +128,7 @@ def make_arg_parser(): help='abort program and dump core') return parser + def main(): arg_parser = make_arg_parser() args = arg_parser.parse_args() @@ -107,5 +137,6 @@ def main(): executable = Null(args, args.verbose) executable.execute() + if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/apps/cli/executables/null/test/test_null.py b/apps/cli/executables/null/test/test_null.py index 7dd49717c246daa13db90f959016aa4ce59af8dc..83f6b5fc4564742e1c7a881ec33707bf58d6f088 100644 --- a/apps/cli/executables/null/test/test_null.py +++ b/apps/cli/executables/null/test/test_null.py @@ -1,36 +1,41 @@ import pytest import argparse -# from null.null import Null -from ..src.null.null import Null +from null.null import Null + @pytest.fixture() def null(): null = Null(argparse.Namespace(), True) return null + def test_print_error(null, caplog): null.print_error() assert 'ERROR: This is an error.' in caplog.text + def test_print_greeting(null, caplog): null.print_greeting() assert 'Hello, world!' in caplog.text assert 'And goodbye, world...' in caplog.text + def test_exit_with_failure(null, caplog): with pytest.raises(SystemExit) as e: null.exit_with_failure() assert 'Error purposefully induced. Exiting with status code -1...' in caplog.text assert e.value.code == -1 + def test_exit_randomly(null, caplog): with pytest.raises(SystemExit) as e: null.exit_randomly() assert 'Exiting with status code' in caplog.text assert -50 <= e.value.code <= 50 + def test_take_nap(null, caplog): null.take_nap() assert 'Going to sleep...' in caplog.text - assert 'Waking up.' in caplog.text \ No newline at end of file + assert 'Waking up.' in caplog.text diff --git a/apps/cli/executables/null/__init__.py b/build/recipes/build_pkgs/__init__.py similarity index 100% rename from apps/cli/executables/null/__init__.py rename to build/recipes/build_pkgs/__init__.py diff --git a/build/recipes/build_pkgs/build_pkgs.py b/build/recipes/build_pkgs/build_pkgs.py index f542b7b3b63d112b31c109be6e3f02f4552439f3..7b3324ad8d465a489f0302974870a1b2fd0d8015 100644 --- a/build/recipes/build_pkgs/build_pkgs.py +++ b/build/recipes/build_pkgs/build_pkgs.py @@ -1,10 +1,18 @@ +import logging import subprocess +from typing import List, Dict, Any, Callable, Optional -def get_dirs(): +from zc.buildout.buildout import Buildout, Options + +logger = logging.getLogger("buildout/build_pkgs") + + +def get_dirs() -> List[str]: """ Finds all subdirectories containing setup.py files. :return: List of directories as strings. """ + logger.debug("Getting list of directories containing setup.py files...") find = subprocess.run([ 'find', '.', '-name', 'setup.py', '-not', '-path', './build/recipes/*' ], stdout=subprocess.PIPE) @@ -14,14 +22,17 @@ def get_dirs(): for i, d in enumerate(dirs_cpy): dirs[i] = d.replace('/setup.py', '') + logger.debug("Done getting directories.") return dirs -def get_names(dirs): + +def get_names(dirs: List[str]) -> List[str]: """ Generate list of subproject names based on the rule that the name of the subproject directory will be the name of the subproject. :return: List of names as strings. """ + logger.debug("Generating list of subproject names...") names = [] for d in dirs: if d != '': @@ -32,10 +43,12 @@ def get_names(dirs): name = "services" names.append(name) + logger.debug("Done generating.") return names + class Recipe: - def __init__(self, buildout, name, options): + def __init__(self, buildout: Optional[Buildout], name: str, options: Options): """ Initializes fields needed for recipe. :param buildout: (Boilerplate) Dictionary of options from buildout section @@ -47,19 +60,22 @@ class Recipe: self.options = options self.pkg_list = get_names(get_dirs()) - def install(self): + def install(self) -> Any: """ Install method that runs when recipe has components it needs to install. :return: Paths to files, as strings, created by the recipe. """ if self.options['name'] == "all": + logger.warning("WARNING: You've requested all packages to be built. This will take a long time.") + logger.warning("If only one or a few packages have changed, consider specifying them " + "in a comma-separated list.") pkgs = self.pkg_list else: pkgs = self.options['name'].split(',') for p in pkgs: if p not in self.pkg_list or p == '': - print("Package {} not valid. Skipping.".format(p)) + logger.error(f"Package {p} not valid. Skipping.") continue subprocess.run(["conda", "build", "build/metadata/{}".format(p), "--output-folder", "build/pkgs/"], stdout=subprocess.PIPE) @@ -69,4 +85,4 @@ class Recipe: return self.options.created() - update = install \ No newline at end of file + update = install diff --git a/build/recipes/build_pkgs/setup.py b/build/recipes/build_pkgs/setup.py index db69c236a8a32ec6f0a16d2be9ca896b83df5875..04e432da6c878e5a4f3243cdf83b10081671854a 100644 --- a/build/recipes/build_pkgs/setup.py +++ b/build/recipes/build_pkgs/setup.py @@ -3,6 +3,6 @@ from setuptools import setup setup( name='build_pkgs', version='0.1', - py_modules = ['build_pkgs'], - entry_points = {"zc.buildout": ["default=build_pkgs:Recipe"]}, -) \ No newline at end of file + py_modules=['build_pkgs'], + entry_points={"zc.buildout": ["default=build_pkgs:Recipe"]}, +) diff --git a/build/recipes/build_pkgs/test/__init__.py b/build/recipes/build_pkgs/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/build/recipes/build_pkgs/test/conftest.py b/build/recipes/build_pkgs/test/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..46126efdfc2d3f2a05a8cf0e83d51c1acc8bdc3f --- /dev/null +++ b/build/recipes/build_pkgs/test/conftest.py @@ -0,0 +1,18 @@ +import pytest +import zc.buildout.testing +from .. import build_pkgs + + +@pytest.fixture(scope='module') +def recipe() -> build_pkgs.Recipe: + """ + pytest fixture that initializes zc.buildout objects for use in testing. + Initializes Buildout, Options, and Recipe objects. + + :return: Initialized recipe object for build_pkgs + """ + from .. import build_pkgs + buildout = zc.buildout.testing.Buildout() + options = buildout.Options(buildout, 'build_pkgs', {'recipe': 'build_pkgs', 'name': 'null'}) + recipe = build_pkgs.Recipe(buildout=buildout, name=None, options=options) + return recipe diff --git a/build/recipes/build_pkgs/test/test_build_pkgs.py b/build/recipes/build_pkgs/test/test_build_pkgs.py new file mode 100644 index 0000000000000000000000000000000000000000..188c5e670ca796fad5dc6d275b60b6b9670b71c2 --- /dev/null +++ b/build/recipes/build_pkgs/test/test_build_pkgs.py @@ -0,0 +1,32 @@ +import os +from typing import List + +from .. import build_pkgs + + +class TestBuildPkgs: + def test_get_names(self): + """ + Test that build_pkgs correctly gets the package name from + :return: + """ + d = 'apps/cli/executables/null' + assert build_pkgs.get_names([d]) == ['null'] + + def test_get_dirs(self): + """ + Test that build_pkgs correctly finds and stores directory paths + of directories containing setup.py files. + """ + assert './apps/cli/executables/null' in build_pkgs.get_dirs() + + def test_output(self, recipe: build_pkgs.Recipe): + """ + Test that the package specified in the recipe has been built correctly. + """ + created = recipe.install() + + for path in created: + if len(path) > 0: + assert path is not None, "conda build failed to build package" + assert os.path.exists(path) diff --git a/build/recipes/setup_to_meta/__init__.py b/build/recipes/setup_to_meta/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/build/recipes/setup_to_meta/setup.py b/build/recipes/setup_to_meta/setup.py index 24ade227647ff5b327d2f7a892a04a6a98ea004e..8a2ebea1329a7390c9016fa06838e78cb2843ecf 100644 --- a/build/recipes/setup_to_meta/setup.py +++ b/build/recipes/setup_to_meta/setup.py @@ -3,6 +3,6 @@ from setuptools import setup setup( name='setup_to_meta', version='0.1', - py_modules = ['setup_to_meta'], - entry_points = {"zc.buildout": ["default=setup_to_meta:Recipe"]}, -) \ No newline at end of file + py_modules=['setup_to_meta'], + entry_points={"zc.buildout": ["default=setup_to_meta:Recipe"]}, +) diff --git a/build/recipes/setup_to_meta/setup_to_meta.py b/build/recipes/setup_to_meta/setup_to_meta.py index e65c1ba2ae9e104a35dfb543cc9985c0224ab107..783afd3766ca01ad586f53953463bde491396c94 100644 --- a/build/recipes/setup_to_meta/setup_to_meta.py +++ b/build/recipes/setup_to_meta/setup_to_meta.py @@ -1,13 +1,22 @@ +import os import json +import logging +import subprocess +from typing import List, Any, Dict, Callable, Optional -import setuptools, importlib, subprocess, os, re +from zc.buildout.buildout import Buildout, Options PYTHON_VERSION = '3.8' +logger = logging.getLogger("buildout/setup_to_meta") -def write_metafile(metadata, filepath): + +def write_metafile(metadata: str, filepath: str): """ Writes given metadata to file with given path. + :param metadata: String containing conda recipe metadata to be written + :param filepath: String containing the path to conda recipe file (meta.yaml) """ + logger.debug(f"Writing meta.yaml file at {filepath}...") try: os.makedirs(filepath[:-10]) except FileExistsError: @@ -15,18 +24,18 @@ def write_metafile(metadata, filepath): with open(filepath, 'w') as f: f.write(metadata) + logger.debug("Done writing.") class MetadataGenerator: """ Uses given info extracted from setup.py file to fill out metadata template. """ - - def __init__(self, setup, path): + def __init__(self, setup: Dict[str, str], path: str): self.setup = setup self.path = path - def fmt_ep(self): + def fmt_ep(self) -> str: """ Format entry points section of metadata. :return: Formatted string if entry points exists; else empty string. @@ -39,7 +48,7 @@ class MetadataGenerator: ep_string += ' ' return ep_string - def fmt_reqs(self): + def fmt_reqs(self) -> str: """ Format requirements section of metadata. :return: Formatted string if requirements exists; else empty string. @@ -62,7 +71,7 @@ class MetadataGenerator: '\n' return reqs_string - def fmt_test(self): + def fmt_test(self) -> str: """ Format test section of metadata. NOTE: May need further tweaking to be smarter based on individual project @@ -88,7 +97,8 @@ class MetadataGenerator: ) return test_string - def generate(self): + def generate(self) -> str: + logger.debug(f"Generating meta.yaml file from {self.path}...") # Filter numpy etc. out of the requirements try: self.setup['install_requires'] = [req for req in self.setup['install_requires'] if req != 'numpy'] @@ -106,7 +116,8 @@ class MetadataGenerator: with open('build/tools/metafile_template.txt', 'r') as f: metadata = f.read() - + + logger.debug("Done generating.") return metadata.format( name = name, version = version, @@ -119,22 +130,25 @@ class MetadataGenerator: ) -def parse_setup(d): +def parse_setup(d: str) -> Dict[str, str]: """ Function for running parse_setup.py on each directory with a setup.py file. NOTE: Contains a hack for getting parse_setup.py to run in each directory. :param d: Directory with a setup.py file. :return: Data collected from parse_setup.py. """ + logger.debug(f"Parsing setup.py at {d}...") subprocess.run(['cp', 'build/tools/parse_setup.py', d]) os.chdir(d) proc = subprocess.run(['python3', 'parse_setup.py'], stdout=subprocess.PIPE) os.chdir(root) subprocess.run(['rm', '{}/parse_setup.py'.format(d)]) + logger.debug("Done parsing.") return json.loads(proc.stdout) -def get_outputs(names): + +def get_outputs(names: List[str]) -> List[str]: """ Generate list of metadata files that will be created. :param dirs: List of dirs of all subprojects with a setup.py file. @@ -146,11 +160,12 @@ def get_outputs(names): return outputs -def get_dirs(): +def get_dirs() -> List[str]: """ Finds all subdirectories containing setup.py files. :return: List of directories as strings. """ + logger.debug("Finding list of directories containing setup.py files...") find = subprocess.run([ 'find', '.', '-name', 'setup.py', '-not', '-path', './build/recipes/*' ], stdout=subprocess.PIPE) @@ -160,14 +175,17 @@ def get_dirs(): for i, d in enumerate(dirs_cpy): dirs[i] = d.replace('/setup.py', '') + logger.debug("Done finding directories.") return dirs -def get_names(dirs): + +def get_names(dirs: List[str]) -> List[str]: """ Generate list of subproject names based on the rule that the name of the subproject directory will be the name of the subproject. :return: List of names as strings. """ + logger.debug("Getting list of names...") names = [] for d in dirs: if d != '': @@ -178,9 +196,11 @@ def get_names(dirs): name = "services" names.append(name) + logger.debug("Done getting list of names.") return names -def del_substrings(s, substrings): + +def del_substrings(s: str, substrings: List[str]): """ Function for deleting multiple substrings from a string. :param s: String to be modified. @@ -192,15 +212,17 @@ def del_substrings(s, substrings): return s + root = os.getcwd() + class Recipe: """ Buildout Recipe class. For more detailed information, see the link. http://www.buildout.org/en/latest/topics/writing-recipes.html """ - def __init__(self, buildout, name, options): + def __init__(self, buildout: Optional[Buildout], name: Optional[str], options: Options): """ Initializes fields needed for recipe. :param buildout: (Boilerplate) Dictionary of options from buildout section @@ -213,8 +235,7 @@ class Recipe: self.outputs = get_outputs(self.names) self.options = options - # TODO: Keep track of path in setup_dict - def install(self): + def install(self) -> Any: """ Install method that runs when recipe has components it needs to install. :return: Paths to files, as strings, created by the recipe. @@ -224,10 +245,10 @@ class Recipe: setup_data = parse_setup(d) metadata = MetadataGenerator(setup_data, d).generate() write_metafile(metadata, self.outputs[i]) - # Pass created file into options.created() + # Buildout-specific operation: pass created file into options.created() self.options.created(self.outputs[i]) return self.options.created() # No special procedure for updating vs. installing - update = install \ No newline at end of file + update = install diff --git a/build/recipes/setup_to_meta/test/__init__.py b/build/recipes/setup_to_meta/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/build/recipes/setup_to_meta/test/conftest.py b/build/recipes/setup_to_meta/test/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..877092d84ed217e63b96ae18f0b49205f9d70abd --- /dev/null +++ b/build/recipes/setup_to_meta/test/conftest.py @@ -0,0 +1,27 @@ +import pytest +import zc.buildout.testing + +from .. import setup_to_meta + + +@pytest.fixture(scope='module') +def recipe() -> setup_to_meta.Recipe: + """ + pytest fixture that initializes zc.buildout objects for use in testing. + Initializes Buildout, Options, and Recipe objects. + + :return: Initialized recipe object for setup_to_meta + """ + from .. import setup_to_meta + buildout = zc.buildout.testing.Buildout() + options = buildout.Options( + buildout, + 'gen_metadata', + {'recipe': 'setup_to_meta'} + ) + recipe = setup_to_meta.Recipe( + buildout=buildout, + name=None, + options=options + ) + return recipe diff --git a/build/recipes/setup_to_meta/test/test_setup_to_meta.py b/build/recipes/setup_to_meta/test/test_setup_to_meta.py new file mode 100644 index 0000000000000000000000000000000000000000..8cb514d110f903e577b61fdaa503fe139a9bad03 --- /dev/null +++ b/build/recipes/setup_to_meta/test/test_setup_to_meta.py @@ -0,0 +1,56 @@ +from typing import Dict, List + +from .. import setup_to_meta + + +class TestSetupToMeta: + def test_del_substrings(self): + """ + Tests that del_substrings function properly deletes substrings from a given string + """ + replaced = setup_to_meta.del_substrings('heallob, woarlcd', ['a', 'b', 'c']) + assert replaced == 'hello, world' + + def test_get_names(self): + """ + Tests that setup_to_meta correctly gets the package name from the package path + """ + d = 'apps/cli/executables/null' + assert setup_to_meta.get_names([d]) == ['null'] + + def test_get_dirs(self): + """ + Tests that setup_to_meta correctly finds and stores directory paths + of packages containing setup.py files + """ + assert './apps/cli/executables/null' in setup_to_meta.get_dirs() + + def test_get_outputs(self): + """ + Tests that setup_to_meta correctly generates a list of output paths given + a list of package names. + """ + assert setup_to_meta.get_outputs(['null']) == ['build/metadata/null/meta.yaml'] + + def test_parse_setup(self): + """ + Tests that parse_setup correctly parses a setup.py file into a dictionary. + """ + setup_data = setup_to_meta.parse_setup('apps/cli/executables/null') + keys = ['name', 'version', 'description', 'license'] + for key in keys: + assert key in setup_data + + def test_output(self, recipe: setup_to_meta.Recipe): + """ + Test that metadata was successfully created and contains data. + + Checking for 'package' is an arbitrary check for content that will always + occur in a correct recipe. + :param recipe: Fixture that initializes recipe class in setup_to_meta.py + """ + created = recipe.install() + + for path in created: + with open(path, 'r') as f: + assert 'package' in f.read() diff --git a/build/recipes/test_recipes/__init__.py b/build/recipes/test_recipes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/build/recipes/test_recipes/setup.py b/build/recipes/test_recipes/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..c97dae42aaa38bdbdab402d89a8a0d437d0f7071 --- /dev/null +++ b/build/recipes/test_recipes/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup + +setup( + name='test_recipes', + version='0.1', + py_modules = ['test_recipes'], + entry_points = {"zc.buildout": ["default=test_recipes:Recipe"]}, +) \ No newline at end of file diff --git a/build/recipes/test_recipes/test/__init__.py b/build/recipes/test_recipes/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/build/recipes/test_recipes/test/test_test_recipes.py b/build/recipes/test_recipes/test/test_test_recipes.py new file mode 100644 index 0000000000000000000000000000000000000000..912bc274ba93235ccdc85c82e1949993855cafd5 --- /dev/null +++ b/build/recipes/test_recipes/test/test_test_recipes.py @@ -0,0 +1,13 @@ +from .. import test_recipes + + +class TestRecipes: + def test_get_recipes(self): + """ + Test that test_recipes is able to successfully retrieve names and paths + for all buildout recipes. + """ + recipe_names = ['setup_to_meta', 'build_pkgs', 'test_recipes'] + recipes = test_recipes.get_recipes() + for recipe in recipe_names: + assert recipe in recipes diff --git a/build/recipes/test_recipes/test_recipes.py b/build/recipes/test_recipes/test_recipes.py new file mode 100644 index 0000000000000000000000000000000000000000..fd09a2c49ee94434e2a4e6823c0be5029e3af9da --- /dev/null +++ b/build/recipes/test_recipes/test_recipes.py @@ -0,0 +1,76 @@ +import subprocess +import logging +from typing import Dict, Optional + +from zc.buildout.buildout import Buildout, Options + +logger = logging.getLogger("buildout/test_recipes") + + +def get_recipes() -> Dict[str, str]: + """ + Get all currently installed buildout recipes (including this one!) + :return: Dictionary with format {recipe_name: recipe_path_from_root,} + """ + logger.debug("Getting list of recipes...") + recipes = {} + find = subprocess.run(['find', './build/recipes', '-name', 'setup.py'], + stdout=subprocess.PIPE) + paths = find.stdout.decode('utf-8').split('\n') + dirs = [] + names = [] + for p in paths: + if len(p) > 1: + # Exclude empty result from find + dirs.append(p.replace('/setup.py', '')) + names.append(p.split('/')[-2]) + for d, n in zip(dirs, names): + recipes[n] = d + logger.debug("Done getting recipes.") + + return recipes + + +class Recipe: + """ + Buildout Recipe class. + For more detailed information, see the link. + http://www.buildout.org/en/latest/topics/writing-recipes.html + """ + + def run_test(self, recipe: str): + """ + Run test for given recipe. + :param recipe: Name of recipe to be run. + """ + logger.debug(f"Running tests for recipe {recipe}...") + subprocess.run(['pytest', '-vv', '--log-level=DEBUG', '--showlocals', + self.recipes[recipe]]) + + def __init__(self, buildout: Optional[Buildout], name: str, options: Options): + """ + Initializes fields needed for recipe. + :param buildout: (Boilerplate) Dictionary of options from buildout section + of buildout.cfg + :param name: (Boilerplate) Name of section that uses this recipe. + :param options: (Boilerplate) Options of section that uses this recipe. + """ + self.name = name + self.options = options + self.recipes = get_recipes() + self.choice = options['name'] + + def install(self): + """ + Install method that runs when recipe has components it needs to install. + + In this case, nothing is "installed" per se. + :return: Paths to files, as strings, created by the recipe. + """ + if self.choice == 'all': + # Run tests for all recipes + for recipe in self.recipes: + self.run_test(recipe) + else: + if self.choice in self.recipes: + self.run_test(self.choice) diff --git a/build/tools/parse_setup.py b/build/tools/parse_setup.py index 8acd807b95980c86bcedb4f1a2b220ea5300da79..6d1c8ab8fd78bd0096d40322fc3ca926dacf5aea 100644 --- a/build/tools/parse_setup.py +++ b/build/tools/parse_setup.py @@ -3,6 +3,8 @@ import setuptools import json data = {} + + def my_setup(*args, **kwargs): """ A replacement for setuptools.setup(). @@ -20,6 +22,7 @@ def my_setup(*args, **kwargs): for field in fields: data[field] = kwargs.get(field) + def main(): # Author of these shenanigans: Daniel Lyons (but you already knew that) @@ -36,5 +39,6 @@ def main(): # Instead of exiting, we now have populated our global variable, without doing any parsing json.dump(data, sys.stdout) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/build/tools/transfer_to_builder.py b/build/tools/transfer_to_builder.py index 64d7e4fc7ff61c4fdc8b32d4f8c7fd627f7981d8..4f9b67094afb4ecaafb325c1b8b7635dffa36dca 100644 --- a/build/tools/transfer_to_builder.py +++ b/build/tools/transfer_to_builder.py @@ -1,25 +1,38 @@ -import subprocess -import paramiko -import fnmatch import os +import sys +import fnmatch import getpass +import logging +from typing import List + +import paramiko +import subprocess from scp import SCPClient -def get_build_pkg_names(): +logger = logging.getLogger("buildtools/transfer_to_builder") +logger.setLevel(logging.INFO) +hander = logging.StreamHandler(stream=sys.stdout) + + +def get_build_pkg_names() -> List[str]: """ Search through pkgs directory for built .tar.bz2 packages :return: List of package archive file names """ pkg_names = [] d = "build/pkgs/noarch/" - for file in os.listdir(d): - if fnmatch.fnmatch(file, "*.tar.bz2"): - pkg_names.append(d + file) + try: + for file in os.listdir(d): + if fnmatch.fnmatch(file, "*.tar.bz2"): + pkg_names.append(d + file) + except FileNotFoundError as e: + logger.error(e) return pkg_names -def create_ssh_client(server): + +def create_ssh_client(server: str) -> paramiko.SSHClient: """ Use paramiko to load SSH keys if they exist and set up an SSH connection to a server. :param server: The server to connect to @@ -29,17 +42,28 @@ def create_ssh_client(server): client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - username = input("Enter NRAO username: ") - password = getpass.getpass(prompt="Enter NRAO password: ") + while True: + username = input("Enter NRAO username: ") + password = getpass.getpass(prompt="Enter NRAO password: ") + + try: + client.connect(server, username=username, password=password) + except paramiko.AuthenticationException as e: + logger.error(e) + logger.error("Invalid credentials. Try again.") + continue + break - client.connect(server, username=username, password=password) return client -def transfer_packages(pkg_names): + +def transfer_packages(pkg_names: List[str]): """ Use shell commands to transfer build archives to builder and update its conda package index. :param pkg_names: Names of the .tar.bz2 files for the built packages. """ + logger.addHandler(hander) + if len(pkg_names): builder_addr = "builder.aoc.nrao.edu" builder_path = "/home/builder.aoc.nrao.edu/content/conda/noarch" @@ -54,7 +78,9 @@ def transfer_packages(pkg_names): cmd_index + " && " + cmd_chmod]) else: - print("No packages found in build/pkgs/noarch. Did conda build successfully build the package(s)?") + logger.error("No packages found in build/pkgs/noarch. " + "Did conda build successfully build the package(s)?") + if __name__ == "__main__": - transfer_packages(get_build_pkg_names()) \ No newline at end of file + transfer_packages(get_build_pkg_names()) diff --git a/buildout.cfg b/buildout.cfg index 5ae4f1cc3931524a3c061e563efccd0d0a489b60..58fcedbeb7a329c3e1ca06c0e77023c95c2ff0b6 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -3,13 +3,22 @@ # e.g. buildout parts=build_pkgs will specify build_pkgs # as a part that should be installed [buildout] -develop = build/recipes/setup_to_meta build/recipes/build_pkgs +develop = build/recipes/setup_to_meta build/recipes/build_pkgs build/recipes/test_recipes + +# Section for testing buildout recipes +# Depends on `name` in [buildout] to specify which recipe to test +# Specify name via command line using `buildout parts=test name={name}` +# Specify 'all' to test all recipes +[test] +recipe = test_recipes +name = ${buildout:name} # Section for building internal tools using conda # Depends on gen_metadata # Depends on `name` in [buildout] to specify which package to install # Specify name via command line using # `buildout parts=build_pkgs name={name}` +# Specify 'all' to build all packages (this takes a LONG time) [build_pkgs] => gen_metadata recipe = build_pkgs