|
@@ -0,0 +1,131 @@
|
|
|
|
|
+"""Common objects and base classes in the CI hierarchy"""
|
|
|
|
|
+
|
|
|
|
|
+import abc
|
|
|
|
|
+import os
|
|
|
|
|
+
|
|
|
|
|
+from contextlib import contextmanager
|
|
|
|
|
+from dataclasses import dataclass
|
|
|
|
|
+from enum import Enum, auto
|
|
|
|
|
+from functools import reduce
|
|
|
|
|
+from types import SimpleNamespace
|
|
|
|
|
+from typing import Any, Callable, Iterator, Literal, overload
|
|
|
|
|
+
|
|
|
|
|
+from pydantic import BaseModel, Field
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Status(Enum):
|
|
|
|
|
+ """Result status of a runner, higher numbers take priority"""
|
|
|
|
|
+
|
|
|
|
|
+ SKIPPED = auto()
|
|
|
|
|
+ SUCCESS = auto()
|
|
|
|
|
+ FAILURE = auto()
|
|
|
|
|
+ CANCELLED = auto()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Inputs(BaseModel):
|
|
|
|
|
+ """Stub class describing input arguments"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Outputs(BaseModel):
|
|
|
|
|
+ """Stub class describing result data"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Ref(str):
|
|
|
|
|
+ """Annotation class describing a reference into Context or another place"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass
|
|
|
|
|
+class Factory:
|
|
|
|
|
+ """Annotation class describing a non-trivial synthesized property"""
|
|
|
|
|
+
|
|
|
|
|
+ __call__: Callable[[Context], Any]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Context(SimpleNamespace):
|
|
|
|
|
+ """Wrapper class for the context of the CI runtime"""
|
|
|
|
|
+
|
|
|
|
|
+ def access(self, ctx: str) -> Any:
|
|
|
|
|
+ """Accessor for context state with a dot-separated path"""
|
|
|
|
|
+ path = ctx.split(".")
|
|
|
|
|
+ if path[0] == "context":
|
|
|
|
|
+ return reduce(getattr, path[1:], self)
|
|
|
|
|
+
|
|
|
|
|
+ if path[0] == "env":
|
|
|
|
|
+ assert len(path) == 2
|
|
|
|
|
+ return os.environ.get(path[1])
|
|
|
|
|
+
|
|
|
|
|
+ raise KeyError(ctx)
|
|
|
|
|
+
|
|
|
|
|
+ @overload
|
|
|
|
|
+ def fabricate(self, state: BaseModel, attr: Literal["inputs"]) -> Inputs: ...
|
|
|
|
|
+
|
|
|
|
|
+ @overload
|
|
|
|
|
+ def fabricate(self, state: BaseModel, attr: Literal["outputs"]) -> Outputs: ...
|
|
|
|
|
+
|
|
|
|
|
+ def fabricate(
|
|
|
|
|
+ self, state: BaseModel, attr: Literal["inputs"] | Literal["outputs"]
|
|
|
|
|
+ ) -> Inputs | Outputs:
|
|
|
|
|
+ """Fabricate and validate an Inputs or Outputs object"""
|
|
|
|
|
+ model = getattr(state, attr)
|
|
|
|
|
+ if model is None:
|
|
|
|
|
+ annotation = state.__pydantic_fields__[attr].annotation
|
|
|
|
|
+ assert annotation is not None
|
|
|
|
|
+ model = annotation()
|
|
|
|
|
+
|
|
|
|
|
+ for k, field in model.__pydantic_fields__.items():
|
|
|
|
|
+ value = getattr(model, k)
|
|
|
|
|
+ if field.annotation is None:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ if isinstance(value, Ref):
|
|
|
|
|
+ setattr(model, k, self.access(value))
|
|
|
|
|
+ elif isinstance(value, Factory):
|
|
|
|
|
+ setattr(model, k, value(self))
|
|
|
|
|
+
|
|
|
|
|
+ _validate(model)
|
|
|
|
|
+ setattr(state, attr, model)
|
|
|
|
|
+ return model
|
|
|
|
|
+
|
|
|
|
|
+ @contextmanager
|
|
|
|
|
+ def extend(self, **kwargs: Any) -> Iterator[Context]:
|
|
|
|
|
+ """Create a new context that inherits an extra property"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ yield Context(**vars(self), **kwargs)
|
|
|
|
|
+ finally:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Action(BaseModel, abc.ABC):
|
|
|
|
|
+ """Base class describing all individual steps in a CI workflow"""
|
|
|
|
|
+
|
|
|
|
|
+ name: str
|
|
|
|
|
+ id: str = Field(default="", exclude_if=lambda v: not v)
|
|
|
|
|
+ inputs: Inputs = Inputs()
|
|
|
|
|
+ outputs: Outputs = Outputs()
|
|
|
|
|
+
|
|
|
|
|
+ # pylint: disable=unused-argument
|
|
|
|
|
+ def enabled(self, status: Status, context: Context) -> bool:
|
|
|
|
|
+ """Should this action even be run?"""
|
|
|
|
|
+ return status == Status.SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+ @abc.abstractmethod
|
|
|
|
|
+ def run(self, context: Context) -> Status:
|
|
|
|
|
+ """Run this action, updating outputs internally or via the cipy.runner.run wrapper"""
|
|
|
|
|
+
|
|
|
|
|
+ def cleanup(self, context: Context) -> None:
|
|
|
|
|
+ """Optionally clean up after ourselves"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _validate(model: BaseModel):
|
|
|
|
|
+ """Perform the actual model validation that we sabotaged w/ required() and similar functions"""
|
|
|
|
|
+ for k, field in model.__pydantic_fields__.items():
|
|
|
|
|
+ attr = getattr(model, k)
|
|
|
|
|
+ if field.annotation is None:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ if isinstance(attr, (Ref, Factory)):
|
|
|
|
|
+ raise TypeError(f"field '{k}' in {type(model)} is unset")
|
|
|
|
|
+ if not isinstance(attr, field.annotation):
|
|
|
|
|
+ raise TypeError(
|
|
|
|
|
+ f"field '{k}' in {type(model)} is of the wrong type (should be {field.annotation})"
|
|
|
|
|
+ )
|