Example #1
0
def task(cls):
    """
    A decorator which turns a class into a Zookeeper task, which is a Zookeeper
    method with an argument-less `run` method.

    Tasks are runnable through the CLI. Upon execution, the task is instantiated
    and all component fields are configured using configuration passed as CLI
    arguments of the form `field_name=field_value`, and then the `run` method is
    called.
    """
    cls = component(cls)

    if not (hasattr(cls, "run") and callable(cls.run)):
        raise TypeError("Classes decorated with @task must define a `run` method.")

    # Enforce argument-less `run`.

    call_args = inspect.signature(cls.run).parameters
    if len(call_args) > 1 or len(call_args) == 1 and "self" not in call_args:
        raise TypeError(
            "A @task class must define a `run` method taking no arguments except "
            f"`self`, which runs the task, but `{cls.__name__}.run` accepts arguments "
            f"{tuple(name for name in call_args)}."
        )

    # Register a CLI command to run the task.

    if convert_to_snake_case(cls.__name__) in (
        convert_to_snake_case(c) for c in cli.commands
    ):
        raise ValueError(
            f"Task naming conflict. Task with name '{cls.__name__}' (or similar) "
            "already registered. Note that the task name is the name of the class that "
            "the @task decorator is applied to."
        )

    @cli.command(cls.__name__, context_settings=dict(ignore_unknown_options=True))
    @click.option(
        "-i",
        "--interactive",
        is_flag=True,
        default=False,
        help="Interactively configure task.",
    )
    @click.argument("config", type=ConfigParam(), nargs=-1)
    def command(config, interactive):
        config = {k: v for k, v in config}
        task_instance = cls()
        configure(task_instance, config, interactive=interactive)
        task_instance.run()

    return cls
Example #2
0
def task(cls):
    """
    A decorator which turns a class into a Zookeeper task, which is a Zookeeper
    method with an argument-less `run` method.

    Tasks are runnable through the CLI. Upon execution, the task is instantiated
    and all component fields are configured using configuration passed as CLI
    arguments of the form `field_name=field_value`, and then the `run` method is
    called.
    """

    cls = component(cls)

    if not (hasattr(cls, "run") and callable(cls.run)):
        raise ValueError("Classes decorated with @task must define a `run` method.")

    # Enforce argument-less `run`

    call_args = inspect.signature(cls.run).parameters
    if len(call_args) > 1 or len(call_args) == 1 and "self" not in call_args:
        raise ValueError(
            "A task class must define a `run` method taking no arguments except "
            f"`self`, which runs the task, but `{cls.__name__}.run` accepts arguments "
            f"{call_args}."
        )

    # Register a CLI command to run the task.

    task_name = convert_to_snake_case(cls.__name__)
    if task_name in cli.commands:
        raise ValueError(
            f"Task naming conflict. Task with name '{task_name}' already registered. "
            "Note that the task name is the name of the class that the @task decorator "
            "is applied to, normalised to 'snake case', e.g. 'FooBarTask' -> "
            "'foo_bar_task'."
        )

    @cli.command(task_name)
    @click.argument("config", type=ConfigParam(), nargs=-1)
    @click.option("-i", "--interactive", is_flag=True, default=False)
    def command(config, interactive):
        config = {k: v for k, v in config}
        task_instance = cls()
        configure(task_instance, config, interactive=interactive)
        task_instance.run()

    return cls
Example #3
0
def component(cls):
    """A decorater which turns a class into a Zookeeper component."""

    if not inspect.isclass(cls):
        raise ValueError("Only classes can be decorated with @component.")

    if inspect.isabstract(cls):
        raise ValueError(
            "Abstract classes cannot be decorated with @component.")

    if is_component_class(cls):
        raise ValueError(
            f"The class {cls} is already a component; the @component decorator cannot "
            "be applied again.")

    cls.__component_name__ = convert_to_snake_case(cls.__name__)
    cls.__component_parent__ = None
    cls.__component_configured__ = False
    cls.__component_fields__ = {}

    # Override `__getattribute__`, `__setattr__`, and `__delattr__` to correctly
    # manage getting, setting, and deleting component fields.
    cls.__getattribute__ = getattribute_wrapper(
        cls.__getattribute__)  # type: ignore
    cls.__setattr__ = setattr_wrapper(cls.__setattr__)
    cls.__delattr__ = delattr_wrapper(cls.__delattr__)

    # Override `__dir__` so that field names are included.
    cls.__dir__ = dir_wrapper(cls.__dir__)

    # Override `__init__` to perform component initialisation and (potentially)
    # set key-word args as field values.
    cls.__init__ = init_wrapper(cls.__init__)

    # Components should have nice `__str__` and `__repr__` methods.
    cls.__str__ = __component_str__
    cls.__repr__ = __component_repr__

    return cls
Example #4
0
def configure(
    instance,
    conf: Dict[str, Any],
    name: Optional[str] = None,
    interactive: bool = False,
):
    """
    Configure the component instance with parameters from the `conf` dict.

    Configuration passed through `conf` takes precedence over and will
    overwrite any values already set on the instance - either class defaults
    or those set in `__init__`.
    """

    # Configuration can only happen once.
    if instance.__component_configured__:
        raise ValueError(
            f"Component '{instance.__component_name__}' has already been configured."
        )

    if name is not None:
        instance.__component_name__ = name

    # Set the correct value for each field.
    for field_name, field in instance.__component_fields__.items():
        full_name = f"{instance.__component_name__}.{field_name}"
        field_type_name = (
            field.annotated_type.__name__
            if inspect.isclass(field.annotated_type)
            else str(field.annotated_type)
        )
        component_subclasses = list(generate_component_subclasses(field.annotated_type))

        if field_name in conf:
            field_value = conf[field_name]
            # The configuration value could be a string specifying a component
            # class to instantiate.
            if len(component_subclasses) > 0 and isinstance(field_value, str):
                for subclass in component_subclasses:
                    if (
                        field_value == subclass.__name__
                        or field_value == subclass.__qualname__
                        or convert_to_snake_case(field_value)
                        == convert_to_snake_case(subclass.__name__)
                    ):
                        field_value = subclass()
                        break

            set_field_value(instance, field_name, field_value)

            # If this is a 'raw' value, add a placeholder to `conf` so that it
            # gets picked up correctly in sub-components.
            if (
                field_value != OVERRIDEN_CONF_VALUE
                and field_value != NON_OVERRIDEN_CONF_VALUE
            ):
                conf[field_name] = OVERRIDEN_CONF_VALUE

        # If there's no config value but a value is already set on the instance,
        # we only need to add a placeholder to `conf` to make sure that the
        # value will be accessible from sub-components. `hasattr` isn't safe so
        # we have to check membership directly.
        elif field_name in object.__dir__(instance):  # type: ignore
            conf[field_name] = NON_OVERRIDEN_CONF_VALUE

        # If there is only one concrete component subclass of the annotated
        # type, we assume the user must intend to use that subclass, and so
        # instantiate and use an instance automatically.
        elif len(component_subclasses) == 1:
            component_cls = list(component_subclasses)[0]
            print_formatted_text(
                f"'{type_name_str(component_cls)}' is the only concrete component "
                f"class that satisfies the type of the annotated field '{full_name}'. "
                "Using an instance of this class by default."
            )
            # This is safe because we don't allow `__init__` to have any
            # positional arguments without defaults.
            field_value = component_cls()

            set_field_value(instance, field_name, field_value)

            # Add a placeholder to `conf` to so that this value can be accessed
            # by sub-components.
            conf[field_name] = NON_OVERRIDEN_CONF_VALUE

        # If we are running interactively, prompt for a value.
        elif interactive:
            if len(component_subclasses) > 0:
                component_cls = prompt_for_component_subclass(
                    full_name, component_subclasses
                )
                # This is safe because we don't allow `__init__` to have any
                # positional arguments without defaults.
                field_value = component_cls()
            else:
                field_value = prompt_for_value(full_name, field.annotated_type)

            set_field_value(instance, field_name, field_value)

            # Add a placeholder to `conf` so that this value can be accessed by
            # sub-components.
            conf[field_name] = OVERRIDEN_CONF_VALUE

        # Otherwise, raise an appropriate error.
        else:
            if len(component_subclasses) > 0:
                raise ValueError(
                    f"Annotated field '{full_name}' of type '{field_type_name}' "
                    f"has no configured value. Please configure '{full_name}' with "
                    f"one of the following component subclasses of '{field_type_name}':"
                    + "\n    ".join(
                        [""] + list(type_name_str(c) for c in component_subclasses)
                    )
                )
            raise ValueError(
                "No configuration value found for annotated field "
                f"'{full_name}' of type '{field_type_name}'."
            )

    # Recursively configure any sub-components.
    for field_name, field_type in instance.__component_fields__.items():
        field_value = getattr(instance, field_name)
        full_name = f"{instance.__component_name__}.{field_name}"

        if (
            is_component_class(field_value.__class__)
            and not field_value.__component_configured__
        ):
            # Set the component parent so that inherited fields function
            # correctly.
            field_value.__component_parent__ = instance

            # Configure the nested sub-component. The configuration we use
            # consists of all non-scoped keys and any keys scoped to
            # `field_name`, where the keys scoped to `field_name` override the
            # non-scoped keys.
            non_scoped_conf = {a: b for a, b in conf.items() if "." not in a}
            field_name_scoped_conf = {
                a[len(f"{field_name}.") :]: b
                for a, b in conf.items()
                if a.startswith(f"{field_name}.")
            }
            nested_conf = {**non_scoped_conf, **field_name_scoped_conf}
            configure(field_value, nested_conf, name=full_name, interactive=interactive)

    # Type check all fields.
    for field_name, field in instance.__component_fields__.items():
        assert field_name in instance.__component_fields__
        field_value = getattr(instance, field_name)
        try:
            check_type(field_name, field_value, field.annotated_type)
            # Because boolean `True` and `False` are coercible to ints and
            # floats, `typeguard.check_type` doesn't throw if we e.g. pass
            # `True` to a value expecting a float. This would, however, likely
            # be a user error, so explicitly check for this.
            if field.annotated_type in [float, int] and isinstance(field_value, bool):
                raise TypeError
        except TypeError:
            raise TypeError(
                f"Attempting to set field '{instance.__component_name__}.{field_name}' "
                f"which has annotated type '{type_name_str(field.annotated_type)}' "
                f"with value '{field_value}'."
            ) from None

    instance.__component_configured__ = True
Example #5
0
def configure(
    instance,
    conf: Dict[str, Any],
    name: Optional[str] = None,
    interactive: bool = False,
):
    """
    Configure the component instance with parameters from the `conf` dict.

    Configuration passed through `conf` takes precedence over and will
    overwrite any values already set on the instance - either class defaults
    or those set in `__init__`.
    """
    # Only component instances can be configured.
    if not utils.is_component_instance(instance):
        raise TypeError(
            "Only @component, @factory, and @task instances can be configured. "
            f"Received: {instance}.")

    # Configuration can only happen once.
    if instance.__component_configured__:
        raise ValueError(
            f"Component '{instance.__component_name__}' has already been configured."
        )

    if name is not None:
        instance.__component_name__ = name

    # Set the correct value for each field.
    for field in instance.__component_fields__.values():
        full_name = f"{instance.__component_name__}.{field.name}"
        field_type_name = (field.type.__name__
                           if inspect.isclass(field.type) else str(field.type))

        if isinstance(field, ComponentField):
            # Create a list of all component subclasses of the field type, and
            # add to the list all factory classes which can build the type (or
            # any subclass of the type).
            component_subclasses = list(
                utils.generate_component_subclasses(field.type))
            for type_subclass in utils.generate_subclasses(field.type):
                component_subclasses.extend(
                    FACTORY_REGISTRY.get(type_subclass, []))

        if field.name in conf:
            conf_field_value = conf[field.name]

            if isinstance(field, ComponentField):
                # The configuration value could be a string specifying a component
                # or factory class to instantiate.
                if len(component_subclasses) > 0 and isinstance(
                        conf_field_value, str):
                    for subclass in component_subclasses:
                        if (conf_field_value == subclass.__name__
                                or conf_field_value == subclass.__qualname__ or
                                utils.convert_to_snake_case(conf_field_value)
                                == utils.convert_to_snake_case(
                                    subclass.__name__)):
                            conf_field_value = subclass()
                            break

            # Set the value on the instance.
            instance.__component_configured_field_values__[
                field.name] = conf_field_value

            # This value has now been 'consumed', so delete it from `conf`.
            del conf[field.name]

        # If there's a value in scope, we don't need to do anything.
        elif field.name in instance.__component_fields_with_values_in_scope__:
            pass

        # If the field explicitly allows values to be missing, there's no need
        # to do anything.
        elif field.allow_missing:
            pass

        # If there is only one concrete component subclass of the annotated
        # type, we assume the user must intend to use that subclass, and so
        # instantiate and use an instance automatically.
        elif isinstance(field,
                        ComponentField) and len(component_subclasses) == 1:
            component_cls = list(component_subclasses)[0]
            utils.warn(
                f"'{utils.type_name_str(component_cls)}' is the only concrete component "
                f"class that satisfies the type of the annotated field '{full_name}'. "
                "Using an instance of this class by default.", )
            # This is safe because we don't allow custom `__init__` methods.
            conf_field_value = component_cls()

            # Set the value on the instance.
            instance.__component_configured_field_values__[
                field.name] = conf_field_value

        # If we are running interactively, prompt for a value.
        elif interactive:
            if isinstance(field, ComponentField):
                if len(component_subclasses) > 0:
                    component_cls = utils.prompt_for_component_subclass(
                        full_name, component_subclasses)
                    # This is safe because we don't allow custom `__init__` methods.
                    conf_field_value = component_cls()
                else:
                    raise ValueError(
                        "No component or factory class is defined which satisfies the "
                        f"type {field_type_name} of field {full_name}. If such a class "
                        "has been defined, it must be imported before calling "
                        "`configure`.")
            else:
                conf_field_value = utils.prompt_for_value(
                    full_name, field.type)

            # Set the value on the instance.
            instance.__component_configured_field_values__[
                field.name] = conf_field_value

        # Otherwise, raise an appropriate error.
        else:
            if isinstance(field, ComponentField):
                if len(component_subclasses) > 0:
                    raise ValueError(
                        f"Component field '{full_name}' of type '{field_type_name}' "
                        f"has no default or configured class. Please configure "
                        f"'{full_name}' with one of the following @component or "
                        "@factory classes:" + "\n    ".join([""] + list(
                            utils.type_name_str(c)
                            for c in component_subclasses)))
                else:
                    raise ValueError(
                        f"Component field '{full_name}' of type '{field_type_name}' "
                        f"has no default or configured class. No defined @component "
                        "or @factory class satisfies this type. Please define an "
                        f"@component class subclassing '{field_type_name}', or an "
                        "@factory class with a `build()` method returning a "
                        f"'{field_type_name}' instance. This class must be imported "
                        "before invoking `configure()`.")
            raise ValueError(
                "No configuration value found for annotated field "
                f"'{full_name}' of type '{field_type_name}'.")

        # At this point we are certain that this field has has a value, so keep
        # track of that fact.
        instance.__component_fields_with_values_in_scope__.add(field.name)

    # Check that all `conf` values are being used, and throw if we've been
    # passed an un-used option.
    for key in conf:
        error = ValueError(
            f"Key '{key}' does not correspond to any field of component "
            f"'{instance.__component_name__}'."
            "\n\n"
            "If you have nested components as follows:\n\n"
            "```\n"
            "@component\n"
            "class ChildComponent:\n"
            "    a: int = Field(0)\n"
            "\n"
            "@task\n"
            "class SomeTask:\n"
            "    child: ChildComponent = ComponentField(ChildComponent)\n"
            "    def run(self):\n"
            "        print(self.child.a)\n"
            "```\n\n"
            "then trying to configure `a=<SOME_VALUE>` will fail. You instead need to "
            "fully qualify the key name, and configure the value with "
            "`child.a=<SOME_VALUE>`.")

        if "." in key:
            scoped_component_name = key.split(".")[0]
            if not (scoped_component_name in instance.__component_fields__
                    and isinstance(
                        instance.__component_fields__[scoped_component_name],
                        ComponentField)):
                raise error
        elif key not in instance.__component_fields__:
            raise error

    # Recursively configure any sub-components.
    for field in instance.__component_fields__.values():
        if not isinstance(field, ComponentField):
            continue

        try:
            sub_component_instance = base_getattr(instance, field.name)
        except AttributeError as e:
            if field.allow_missing:
                continue
            raise e from None

        if not utils.is_component_instance(sub_component_instance):
            continue

        full_name = f"{instance.__component_name__}.{field.name}"

        if not sub_component_instance.__component_configured__:
            # Set the component parent so that inherited fields function
            # correctly.
            sub_component_instance.__component_parent__ = instance

            # Extend the field names in scope. All fields with values defined in
            # the scope of the parent are also accessible in the child.
            sub_component_instance.__component_fields_with_values_in_scope__ |= (
                instance.__component_fields_with_values_in_scope__)

            # Configure the nested sub-component. The configuration we use
            # consists of all any keys scoped to `field.name`.
            field_name_scoped_conf = {
                a[len(f"{field.name}."):]: b
                for a, b in conf.items() if a.startswith(f"{field.name}.")
            }
            configure(
                sub_component_instance,
                field_name_scoped_conf,
                name=full_name,
                interactive=interactive,
            )

    instance.__component_configured__ = True

    if hasattr(instance.__class__, "__post_configure__"):
        instance.__post_configure__()
Example #6
0
def configure_component_instance(
    instance,
    conf: Dict[str, Any],
    name: str,
    fields_in_scope: AbstractSet[str],
    interactive: bool,
):
    """Configure the component instance with parameters from the `conf` dict.

    This method is recursively called for each component instance in the component tree
    by the exported `configure` function.
    """
    if name is not None:
        instance.__component_name__ = name

    if hasattr(instance.__class__, "__pre_configure__"):
        conf = instance.__pre_configure__({**conf})
        if not isinstance(conf, dict):
            raise ValueError(
                "Expected the `__pre_configure__` method of component "
                f"'{instance.__component_name__}' to return a dict of configuration, "
                f"but received: {conf}"
            )

    # Extend the field names in scope.
    instance.__component_fields_with_values_in_scope__ |= fields_in_scope

    # Set the correct value for each field.
    for field in instance.__component_fields__.values():
        full_name = f"{instance.__component_name__}.{field.name}"
        field_type_name = (
            field.type.__name__ if inspect.isclass(field.type) else str(field.type)
        )

        if isinstance(field, ComponentField):
            # Create a list of all component subclasses of the field type, and
            # add to the list all factory classes which can build the type (or
            # any subclass of the type).
            component_subclasses = list(utils.generate_component_subclasses(field.type))
            for type_subclass in utils.generate_subclasses(field.type):
                component_subclasses.extend(FACTORY_REGISTRY.get(type_subclass, []))

        if field.name in conf:
            conf_field_value = conf[field.name]

            if isinstance(field, ComponentField):
                # The configuration value could be a string specifying a component
                # or factory class to instantiate.
                if len(component_subclasses) > 0 and isinstance(conf_field_value, str):
                    for subclass in component_subclasses:
                        if (
                            conf_field_value == subclass.__name__
                            or conf_field_value == subclass.__qualname__
                            or utils.convert_to_snake_case(conf_field_value)
                            == utils.convert_to_snake_case(subclass.__name__)
                        ):
                            conf_field_value = subclass()
                            break

            # If this isn't the case, then it's a user type error, but we don't
            # throw here and instead let the run-time type-checking take care of
            # it (which will provide a better error message).
            if utils.is_component_instance(conf_field_value):
                # Set the component parent so that field value inheritence will
                # work correctly.
                conf_field_value.__component_parent__ = instance

            # Set the value on the instance.
            instance.__component_configured_field_values__[
                field.name
            ] = conf_field_value

        # If there's a value in scope, we don't need to do anything.
        elif field.name in instance.__component_fields_with_values_in_scope__:
            pass

        # If the field explicitly allows values to be missing, there's no need
        # to do anything.
        elif field.allow_missing:
            continue

        # If there is only one concrete component subclass of the annotated
        # type, we assume the user must intend to use that subclass, and so
        # instantiate and use an instance automatically.
        elif isinstance(field, ComponentField) and len(component_subclasses) == 1:
            component_cls = list(component_subclasses)[0]
            utils.warn(
                f"'{utils.type_name_str(component_cls)}' is the only concrete component "
                f"class that satisfies the type of the annotated field '{full_name}'. "
                "Using an instance of this class by default.",
            )
            # This is safe because we don't allow custom `__init__` methods.
            conf_field_value = component_cls()
            # Set the component parent so that field value inheritence will work
            # correctly.
            conf_field_value.__component_parent__ = instance

            # Set the value on the instance.
            instance.__component_configured_field_values__[
                field.name
            ] = conf_field_value

        # If we are running interactively, prompt for a value.
        elif interactive:
            if isinstance(field, ComponentField):
                if len(component_subclasses) > 0:
                    component_cls = utils.prompt_for_component_subclass(
                        full_name, component_subclasses
                    )
                    # This is safe because we don't allow custom `__init__` methods.
                    conf_field_value = component_cls()
                else:
                    raise ValueError(
                        "No component or factory class is defined which satisfies the "
                        f"type {field_type_name} of field {full_name}. If such a class "
                        "has been defined, it must be imported before calling "
                        "`configure`."
                    )
            else:
                conf_field_value = utils.prompt_for_value(full_name, field.type)

            # Set the value on the instance.
            instance.__component_configured_field_values__[
                field.name
            ] = conf_field_value

        # Otherwise, raise an appropriate error.
        else:
            if isinstance(field, ComponentField):
                if len(component_subclasses) > 0:
                    raise ValueError(
                        f"Component field '{full_name}' of type '{field_type_name}' "
                        f"has no default or configured class. Please configure "
                        f"'{full_name}' with one of the following @component or "
                        "@factory classes:"
                        + "\n    ".join(
                            [""]
                            + list(utils.type_name_str(c) for c in component_subclasses)
                        )
                    )
                else:
                    raise ValueError(
                        f"Component field '{full_name}' of type '{field_type_name}' "
                        f"has no default or configured class. No defined @component "
                        "or @factory class satisfies this type. Please define an "
                        f"@component class subclassing '{field_type_name}', or an "
                        "@factory class with a `build()` method returning a "
                        f"'{field_type_name}' instance. This class must be imported "
                        "before invoking `configure()`."
                    )
            raise ValueError(
                "No configuration value found for annotated field "
                f"'{full_name}' of type '{field_type_name}'."
            )

        # At this point we are certain that this field has has a value, so keep
        # track of that fact.
        instance.__component_fields_with_values_in_scope__.add(field.name)

    # Check that all `conf` values are being used, and throw if we've been
    # passed an un-used option.
    for key in conf:
        error = ValueError(
            f"Key '{key}' does not correspond to any field of component "
            f"'{instance.__component_name__}'."
            "\n\n"
            "If you have nested components as follows:\n\n"
            "```\n"
            "@component\n"
            "class ChildComponent:\n"
            "    a: int = Field(0)\n"
            "\n"
            "@task\n"
            "class SomeTask:\n"
            "    child: ChildComponent = ComponentField(ChildComponent)\n"
            "    def run(self):\n"
            "        print(self.child.a)\n"
            "```\n\n"
            "then trying to configure `a=<SOME_VALUE>` will fail. You instead need to "
            "fully qualify the key name, and configure the value with "
            "`child.a=<SOME_VALUE>`."
        )

        if "." in key:
            scoped_component_name = key.split(".")[0]
            if not (
                scoped_component_name in instance.__component_fields__
                and isinstance(
                    instance.__component_fields__[scoped_component_name], ComponentField
                )
            ):
                raise error
        elif key not in instance.__component_fields__:
            raise error

    instance.__component_configured__ = True

    if hasattr(instance.__class__, "__post_configure__"):
        instance.__post_configure__()
Example #7
0
 def get_command(self, ctx, cmd_name):
     cmd_name = convert_to_snake_case(cmd_name)
     for c in self.list_commands(ctx):
         if convert_to_snake_case(c) == cmd_name:
             return click.Group.get_command(self, ctx, c)
     return None