From f7706d231d5e151bbe4f8b0b604cc2a6ef33900e Mon Sep 17 00:00:00 2001 From: Todd Gamblin Date: Sat, 21 Dec 2013 17:19:05 -0800 Subject: [PATCH] SPACK-2: Multimethods for specs. - multi_function.py -> multimethod.py - Added @when decorator, which takes a spec and implements matching for method dispatch - Added multimethod unit test, covers basic cases. --- lib/spack/spack/__init__.py | 2 +- lib/spack/spack/multi_function.py | 147 ------------ lib/spack/spack/multimethod.py | 211 ++++++++++++++++++ lib/spack/spack/package.py | 1 - lib/spack/spack/relations.py | 75 +------ lib/spack/spack/spec.py | 28 +++ lib/spack/spack/test/__init__.py | 8 +- .../spack/test/mock_packages/multimethod.py | 39 ++++ lib/spack/spack/test/multimethod.py | 34 +++ lib/spack/spack/test/spec_dag.py | 2 - lib/spack/spack/util/lang.py | 36 +++ 11 files changed, 363 insertions(+), 220 deletions(-) delete mode 100644 lib/spack/spack/multi_function.py create mode 100644 lib/spack/spack/multimethod.py create mode 100644 lib/spack/spack/test/mock_packages/multimethod.py create mode 100644 lib/spack/spack/test/multimethod.py diff --git a/lib/spack/spack/__init__.py b/lib/spack/spack/__init__.py index 3ec0cac441..dfecd8b092 100644 --- a/lib/spack/spack/__init__.py +++ b/lib/spack/spack/__init__.py @@ -4,4 +4,4 @@ from package import Package from relations import depends_on, provides -from multi_function import platform +from multimethod import when diff --git a/lib/spack/spack/multi_function.py b/lib/spack/spack/multi_function.py deleted file mode 100644 index 30146b2139..0000000000 --- a/lib/spack/spack/multi_function.py +++ /dev/null @@ -1,147 +0,0 @@ -"""This module contains utilities for using multi-functions in spack. -You can think of multi-functions like overloaded functions -- they're -functions with the same name, and we need to select a version of the -function based on some criteria. e.g., for overloaded functions, you -would select a version of the function to call based on the types of -its arguments. - -For spack, we might want to select a version of the function based on -the platform we want to build a package for, or based on the versions -of the dependencies of the package. -""" -import sys -import functools - -import spack.architecture -import spack.error as serr - -class NoSuchVersionError(serr.SpackError): - """Raised when we can't find a version of a function for a platform.""" - def __init__(self, fun_name, sys_type): - super(NoSuchVersionError, self).__init__( - "No version of %s found for %s!" % (fun_name, sys_type)) - - -class PlatformMultiFunction(object): - """This is a callable type for storing a collection of versions - of an instance method. The platform decorator (see docs below) - creates PlatformMultiFunctions and registers function versions - with them. - - To register a function, you can do something like this: - pmf = PlatformMultiFunction() - pmf.regsiter("chaos_5_x86_64_ib", some_function) - - When the pmf is actually called, it selects a version of - the function to call based on the sys_type of the object - it is called on. - - See the docs for the platform decorator for more details. - """ - def __init__(self, default=None): - self.function_map = {} - self.default = default - if default: - self.__name__ = default.__name__ - - def register(self, platform, function): - """Register a version of a function for a particular sys_type.""" - self.function_map[platform] = function - if not hasattr(self, '__name__'): - self.__name__ = function.__name__ - else: - assert(self.__name__ == function.__name__) - - def __get__(self, obj, objtype): - """This makes __call__ support instance methods.""" - return functools.partial(self.__call__, obj) - - def __call__(self, package_self, *args, **kwargs): - """Try to find a function that matches package_self.sys_type. - If none is found, call the default function that this was - initialized with. If there is no default, raise an error. - """ - # TODO: make this work with specs. - sys_type = package_self.sys_type - function = self.function_map.get(sys_type, self.default) - if function: - function(package_self, *args, **kwargs) - else: - raise NoSuchVersionError(self.__name__, sys_type) - - def __str__(self): - return "<%s, %s>" % (self.default, self.function_map) - - -class platform(object): - """This annotation lets packages declare platform-specific versions - of functions like install(). For example: - - class SomePackage(Package): - ... - - def install(self, prefix): - # Do default install - - @platform('chaos_5_x86_64_ib') - def install(self, prefix): - # This will be executed instead of the default install if - # the package's sys_type() is chaos_5_x86_64_ib. - - @platform('bgqos_0") - def install(self, prefix): - # This will be executed if the package's sys_type is bgqos_0 - - This allows each package to have a default version of install() AND - specialized versions for particular platforms. The version that is - called depends on the sys_type of SomePackage. - - Note that this works for functions other than install, as well. So, - if you only have part of the install that is platform specific, you - could do this: - - class SomePackage(Package): - ... - - def setup(self): - # do nothing in the default case - pass - - @platform('chaos_5_x86_64_ib') - def setup(self): - # do something for x86_64 - - def install(self, prefix): - # Do common install stuff - self.setup() - # Do more common install stuff - - If there is no specialized version for the package's sys_type, the - default (un-decorated) version will be called. If there is no default - version and no specialized version, the call raises a - NoSuchVersionError. - - Note that the default version of install() must *always* come first. - Otherwise it will override all of the platform-specific versions. - There's not much we can do to get around this because of the way - decorators work. - """ -class platform(object): - def __init__(self, sys_type): - self.sys_type = sys_type - - def __call__(self, fun): - # Record the sys_type as an attribute on this function - fun.sys_type = self.sys_type - - # Get the first definition of the function in the calling scope - calling_frame = sys._getframe(1).f_locals - original_fun = calling_frame.get(fun.__name__) - - # Create a multifunction out of the original function if it - # isn't one already. - if not type(original_fun) == PlatformMultiFunction: - original_fun = PlatformMultiFunction(original_fun) - - original_fun.register(self.sys_type, fun) - return original_fun diff --git a/lib/spack/spack/multimethod.py b/lib/spack/spack/multimethod.py new file mode 100644 index 0000000000..6ea040916f --- /dev/null +++ b/lib/spack/spack/multimethod.py @@ -0,0 +1,211 @@ +"""This module contains utilities for using multi-methods in +spack. You can think of multi-methods like overloaded methods -- +they're methods with the same name, and we need to select a version +of the method based on some criteria. e.g., for overloaded +methods, you would select a version of the method to call based on +the types of its arguments. + +In spack, multi-methods are used to ease the life of package +authors. They allow methods like install() (or other methods +called by install()) to declare multiple versions to be called when +the package is instantiated with different specs. e.g., if the +package is built with OpenMPI on x86_64,, you might want to call a +different install method than if it was built for mpich2 on +BlueGene/Q. Likewise, you might want to do a different type of +install for different versions of the package. + +Multi-methods provide a simple decorator-based syntax for this that +avoids overly complicated rat nests of if statements. Obviously, +depending on the scenario, regular old conditionals might be clearer, +so package authors should use their judgement. +""" +import sys +import functools +import collections + +import spack.architecture +import spack.error +from spack.util.lang import * +from spack.spec import parse_local_spec + + +class SpecMultiMethod(object): + """This implements a multi-method for Spack specs. Packages are + instantiated with a particular spec, and you may want to + execute different versions of methods based on what the spec + looks like. For example, you might want to call a different + version of install() for one platform than you call on another. + + The SpecMultiMethod class implements a callable object that + handles method dispatch. When it is called, it looks through + registered methods and their associated specs, and it tries + to find one that matches the package's spec. If it finds one + (and only one), it will call that method. + + The package author is responsible for ensuring that only one + condition on multi-methods ever evaluates to true. If + multiple methods evaluate to true, this will raise an + exception. + + This is intended for use with decorators (see below). The + decorator (see docs below) creates SpecMultiMethods and + registers method versions with them. + + To register a method, you can do something like this: + mf = SpecMultiMethod() + mf.regsiter("^chaos_5_x86_64_ib", some_method) + + The object registered needs to be a Spec or some string that + will parse to be a valid spec. + + When the pmf is actually called, it selects a version of the + method to call based on the sys_type of the object it is + called on. + + See the docs for decorators below for more details. + """ + def __init__(self, default=None): + self.method_map = {} + self.default = default + if default: + functools.update_wrapper(self, default) + + + def register(self, spec, method): + """Register a version of a method for a particular sys_type.""" + self.method_map[spec] = method + + if not hasattr(self, '__name__'): + functools.update_wrapper(self, method) + else: + assert(self.__name__ == method.__name__) + + + def __get__(self, obj, objtype): + """This makes __call__ support instance methods.""" + return functools.partial(self.__call__, obj) + + + def __call__(self, package_self, *args, **kwargs): + """Try to find a method that matches package_self.sys_type. + If none is found, call the default method that this was + initialized with. If there is no default, raise an error. + """ + spec = package_self.spec + matching_specs = [s for s in self.method_map if s.satisfies(spec)] + + if not matching_specs and self.default is None: + raise NoSuchMethodVersionError(type(package_self), self.__name__, + spec, self.method_map.keys()) + elif len(matching_specs) > 1: + raise AmbiguousMethodVersionError(type(package_self), self.__name__, + spec, matching_specs) + + method = self.method_map[matching_specs[0]] + return method(package_self, *args, **kwargs) + + + def __str__(self): + return "<%s, %s>" % (self.default, self.method_map) + + +class when(object): + """This annotation lets packages declare multiple versions of + methods like install() that depend on the package's spec. + For example: + + .. code-block:: + + class SomePackage(Package): + ... + + def install(self, prefix): + # Do default install + + @when('=chaos_5_x86_64_ib') + def install(self, prefix): + # This will be executed instead of the default install if + # the package's sys_type() is chaos_5_x86_64_ib. + + @when('=bgqos_0") + def install(self, prefix): + # This will be executed if the package's sys_type is bgqos_0 + + This allows each package to have a default version of install() AND + specialized versions for particular platforms. The version that is + called depends on the sys_type of SomePackage. + + Note that this works for methods other than install, as well. So, + if you only have part of the install that is platform specific, you + could do this: + + class SomePackage(Package): + ... + # virtual dependence on MPI. + # could resolve to mpich, mpich2, OpenMPI + depends_on('mpi') + + def setup(self): + # do nothing in the default case + pass + + @when('^openmpi') + def setup(self): + # do something special when this is built with OpenMPI for + # its MPI implementations. + + + def install(self, prefix): + # Do common install stuff + self.setup() + # Do more common install stuff + + There must be one (and only one) @when clause that matches the + package's spec. If there is more than one, or if none match, + then the method will raise an exception when it's called. + + Note that the default version of decorated methods must + *always* come first. Otherwise it will override all of the + platform-specific versions. There's not much we can do to get + around this because of the way decorators work. + """ +class when(object): + def __init__(self, spec): + pkg = get_calling_package_name() + self.spec = parse_local_spec(spec, pkg) + + def __call__(self, method): + # Get the first definition of the method in the calling scope + original_method = caller_locals().get(method.__name__) + + # Create a multimethod out of the original method if it + # isn't one already. + if not type(original_method) == SpecMultiMethod: + original_method = SpecMultiMethod(original_method) + + original_method.register(self.spec, method) + return original_method + + +class MultiMethodError(spack.error.SpackError): + """Superclass for multimethod dispatch errors""" + def __init__(self, message): + super(MultiMethodError, self).__init__(message) + + +class NoSuchMethodVersionError(spack.error.SpackError): + """Raised when we can't find a version of a multi-method.""" + def __init__(self, cls, method_name, spec, possible_specs): + super(NoSuchMethodVersionError, self).__init__( + "Package %s does not support %s called with %s. Options are: %s" + % (cls.__name__, method_name, spec, + ", ".join(str(s) for s in possible_specs))) + + +class AmbiguousMethodVersionError(spack.error.SpackError): + """Raised when we can't find a version of a multi-method.""" + def __init__(self, cls, method_name, spec, matching_specs): + super(AmbiguousMethodVersionError, self).__init__( + "Package %s has multiple versions of %s that match %s: %s" + % (cls.__name__, method_name, spec, + ",".join(str(s) for s in matching_specs))) diff --git a/lib/spack/spack/package.py b/lib/spack/spack/package.py index d372f6b297..dee102e171 100644 --- a/lib/spack/spack/package.py +++ b/lib/spack/spack/package.py @@ -25,7 +25,6 @@ import multiprocessing import url -from spack.multi_function import platform import spack.util.crypto as crypto from spack.version import * from spack.stage import Stage diff --git a/lib/spack/spack/relations.py b/lib/spack/spack/relations.py index b6913d69b8..398f25429f 100644 --- a/lib/spack/spack/relations.py +++ b/lib/spack/spack/relations.py @@ -50,79 +50,18 @@ class Mpileaks(Package): import spack import spack.spec -from spack.spec import Spec import spack.error +from spack.spec import Spec, parse_local_spec from spack.packages import packages_module - - -def _caller_locals(): - """This will return the locals of the *parent* of the caller. - This allows a fucntion to insert variables into its caller's - scope. Yes, this is some black magic, and yes it's useful - for implementing things like depends_on and provides. - """ - stack = inspect.stack() - try: - return stack[2][0].f_locals - finally: - del stack - - -def _get_calling_package_name(): - """Make sure that the caller is a class definition, and return - the module's name. This is useful for getting the name of - spack packages from inside a relation function. - """ - stack = inspect.stack() - try: - # get calling function name (the relation) - relation = stack[1][3] - - # Make sure locals contain __module__ - caller_locals = stack[2][0].f_locals - finally: - del stack - - if not '__module__' in caller_locals: - raise ScopeError(relation) - - module_name = caller_locals['__module__'] - base_name = module_name.split('.')[-1] - return base_name - - -def _parse_local_spec(spec_like, pkg_name): - """Allow the user to omit the package name part of a spec in relations. - e.g., provides('mpi@2', when='@1.9:') says that this package provides - MPI-3 when its version is higher than 1.9. - """ - if not isinstance(spec_like, (str, Spec)): - raise TypeError('spec must be Spec or spec string. Found %s' - % type(spec_like)) - - if isinstance(spec_like, str): - try: - local_spec = Spec(spec_like) - except spack.parse.ParseError: - local_spec = Spec(pkg_name + spec_like) - if local_spec.name != pkg_name: raise ValueError( - "Invalid spec for package %s: %s" % (pkg_name, spec_like)) - else: - local_spec = spec_like - - if local_spec.name != pkg_name: - raise ValueError("Spec name '%s' must match package name '%s'" - % (spec_like.name, pkg_name)) - - return local_spec +from spack.util.lang import * """Adds a dependencies local variable in the locals of the calling class, based on args. """ def depends_on(*specs): - pkg = _get_calling_package_name() + pkg = get_calling_package_name() - dependencies = _caller_locals().setdefault('dependencies', {}) + dependencies = caller_locals().setdefault('dependencies', {}) for string in specs: for spec in spack.spec.parse(string): if pkg == spec.name: @@ -135,11 +74,11 @@ def provides(*specs, **kwargs): 'mpi', other packages can declare that they depend on "mpi", and spack can use the providing package to satisfy the dependency. """ - pkg = _get_calling_package_name() + pkg = get_calling_package_name() spec_string = kwargs.get('when', pkg) - provider_spec = _parse_local_spec(spec_string, pkg) + provider_spec = parse_local_spec(spec_string, pkg) - provided = _caller_locals().setdefault("provided", {}) + provided = caller_locals().setdefault("provided", {}) for string in specs: for provided_spec in spack.spec.parse(string): if pkg == provided_spec.name: diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 9e172f8708..088da7bd98 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -1097,6 +1097,34 @@ def parse(string): return SpecParser().parse(string) +def parse_local_spec(spec_like, pkg_name): + """Allow the user to omit the package name part of a spec if they + know what it has to be already. + + e.g., provides('mpi@2', when='@1.9:') says that this package + provides MPI-3 when its version is higher than 1.9. + """ + if not isinstance(spec_like, (str, Spec)): + raise TypeError('spec must be Spec or spec string. Found %s' + % type(spec_like)) + + if isinstance(spec_like, str): + try: + local_spec = Spec(spec_like) + except spack.parse.ParseError: + local_spec = Spec(pkg_name + spec_like) + if local_spec.name != pkg_name: raise ValueError( + "Invalid spec for package %s: %s" % (pkg_name, spec_like)) + else: + local_spec = spec_like + + if local_spec.name != pkg_name: + raise ValueError("Spec name '%s' must match package name '%s'" + % (local_spec.name, pkg_name)) + + return local_spec + + class SpecError(spack.error.SpackError): """Superclass for all errors that occur while constructing specs.""" def __init__(self, message): diff --git a/lib/spack/spack/test/__init__.py b/lib/spack/spack/test/__init__.py index 22072d12d2..242ddc0991 100644 --- a/lib/spack/spack/test/__init__.py +++ b/lib/spack/spack/test/__init__.py @@ -5,19 +5,24 @@ from spack.colify import colify import spack.tty as tty +"""Names of tests to be included in Spack's test suite""" test_names = ['versions', 'url_parse', 'stage', 'spec_syntax', 'spec_dag', - 'concretize'] + 'concretize', + 'multimethod'] def list_tests(): + """Return names of all tests that can be run for Spack.""" return test_names def run(names, verbose=False): + """Run tests with the supplied names. Names should be a list. If + it's empty, run ALL of Spack's tests.""" verbosity = 1 if not verbose else 2 if not names: @@ -35,6 +40,7 @@ def run(names, verbose=False): testsRun = errors = failures = skipped = 0 for test in names: module = 'spack.test.' + test + print module suite = unittest.defaultTestLoader.loadTestsFromName(module) tty.msg("Running test: %s" % test) diff --git a/lib/spack/spack/test/mock_packages/multimethod.py b/lib/spack/spack/test/mock_packages/multimethod.py new file mode 100644 index 0000000000..7e152f6911 --- /dev/null +++ b/lib/spack/spack/test/mock_packages/multimethod.py @@ -0,0 +1,39 @@ +from spack import * + + +class Multimethod(Package): + """This package is designed for use with Spack's multimethod test. + It has a bunch of test cases for the @when decorator that the + test uses. + """ + + homepage = 'http://www.example.com/' + url = 'http://www.example.com/example-1.0.tar.gz' + + # + # These functions are only valid for versions 1, 2, and 3. + # + @when('@1.0') + def no_version_2(self): + return 1 + + @when('@3.0') + def no_version_2(self): + return 3 + + @when('@4.0') + def no_version_2(self): + return 4 + + + # + # These functions overlap too much, so there is ambiguity + # + @when('@:4') + def version_overlap(self): + pass + + @when('@2:') + def version_overlap(self): + pass + diff --git a/lib/spack/spack/test/multimethod.py b/lib/spack/spack/test/multimethod.py new file mode 100644 index 0000000000..8f63e0bad3 --- /dev/null +++ b/lib/spack/spack/test/multimethod.py @@ -0,0 +1,34 @@ +""" +Test for multi_method dispatch. +""" +import unittest + +import spack.packages as packages +from spack.multimethod import * +from spack.version import * +from spack.spec import Spec +from spack.multimethod import when +from spack.test.mock_packages_test import * + + +class MultiMethodTest(MockPackagesTest): + + def test_no_version_match(self): + pkg = packages.get('multimethod@2.0') + self.assertRaises(NoSuchMethodVersionError, pkg.no_version_2) + + def test_one_version_match(self): + pkg = packages.get('multimethod@1.0') + self.assertEqual(pkg.no_version_2(), 1) + + pkg = packages.get('multimethod@3.0') + self.assertEqual(pkg.no_version_2(), 3) + + pkg = packages.get('multimethod@4.0') + self.assertEqual(pkg.no_version_2(), 4) + + + def test_multiple_matches(self): + pkg = packages.get('multimethod@3.0') + self.assertRaises(AmbiguousMethodVersionError, pkg.version_overlap) + diff --git a/lib/spack/spack/test/spec_dag.py b/lib/spack/spack/test/spec_dag.py index 4d857358c6..d662dd00e1 100644 --- a/lib/spack/spack/test/spec_dag.py +++ b/lib/spack/spack/test/spec_dag.py @@ -14,8 +14,6 @@ from spack.spec import Spec from spack.test.mock_packages_test import * -mock_packages_path = new_path(spack.module_path, 'test', 'mock_packages') - class ValidationTest(MockPackagesTest): diff --git a/lib/spack/spack/util/lang.py b/lib/spack/spack/util/lang.py index bbea0f66a1..1d9e768adc 100644 --- a/lib/spack/spack/util/lang.py +++ b/lib/spack/spack/util/lang.py @@ -9,6 +9,42 @@ ignore_modules = [r'^\.#', '~$'] +def caller_locals(): + """This will return the locals of the *parent* of the caller. + This allows a fucntion to insert variables into its caller's + scope. Yes, this is some black magic, and yes it's useful + for implementing things like depends_on and provides. + """ + stack = inspect.stack() + try: + return stack[2][0].f_locals + finally: + del stack + + +def get_calling_package_name(): + """Make sure that the caller is a class definition, and return + the module's name. This is useful for getting the name of + spack packages from inside a relation function. + """ + stack = inspect.stack() + try: + # get calling function name (the relation) + relation = stack[1][3] + + # Make sure locals contain __module__ + caller_locals = stack[2][0].f_locals + finally: + del stack + + if not '__module__' in caller_locals: + raise ScopeError(relation) + + module_name = caller_locals['__module__'] + base_name = module_name.split('.')[-1] + return base_name + + def attr_required(obj, attr_name): """Ensure that a class has a required attribute.""" if not hasattr(obj, attr_name):