Skip to content

API

This page has been added here as a quick reference for the API of the m.github.actions module.

Action

Bases: BaseModel

A Github action.

The main object to help us define the actions.yaml file. Each repository can declare several actions. For this reason we can declare the actions object as a single Action or a list of them.

We could technically do everything in one single step but when we use external actions then we are forced to split the step. Note that we have no way of having the if fields in each step. This is because we are encouraged to handle that logic in each of the steps we declare. We have full control of the output to stdout and stderr here.

Attributes:

Name Type Description
file_path str

The full path to the action.yaml file.

name str

The name of the action.

description str

Short description for the action.

inputs type[KebabModel] | None

A model describing the inputs for the action. If the action does not need inputs then provide None. It is important to be explicit in this step so that we do not wonder why we cannot use inputs.[argname] when we declare a step.

steps list[RunStep | UsesStep]

The steps for the action

Source code in m/github/actions/actions.py
class Action(BaseModel):
    """A Github action.

    The main object to help us define the `actions.yaml` file. Each repository can
    declare several actions. For this reason we can declare the `actions` object as
    a single `Action` or a list of them.

    We could technically do everything in one single step but when we use external
    actions then we are forced to split the step. Note that we have no way of
    having the `if` fields in each step. This is because we are encouraged to handle
    that logic in each of the steps we declare. We have full control of the output to
    `stdout` and `stderr` here.
    """

    file_path: str = Field(description='The full path to the `action.yaml` file.')

    name: str = Field(description='The name of the action.')

    description: str = Field(description='Short description for the action.')

    inputs: type[KebabModel] | None = Field(description="""
        A model describing the inputs for the action. If the action does not
        need inputs then provide `None`. It is important to be explicit in this step
        so that we do not wonder why we cannot use `inputs.[argname]` when we declare a
        step.
    """)

    steps: list[RunStep | UsesStep] = Field(
        description='The steps for the action',
    )

    def gather_outputs(self: 'Action') -> Res[ActionOutputs]:
        """Obtain a tuple with the action outputs and all steps outputs.

        The steps outputs is a dictionary that maps keys of the form
        [step_id].[step_output_arg] to the output of another step, action
        input or some other value that should be used as input.

        This function validates that all the keys are valid.

        Returns:
            A tuple with the outputs if successful, otherwise an issue.
        """
        action_inputs = self.inputs or KebabModel
        available_outputs: dict[str, str] = {
            f'inputs.{name}': f'inputs.{arg_info.alias}'
            for name, arg_info in action_inputs.model_fields.items()
        }

        outputs: dict[str, MetadataOutput] = {}
        all_issues: dict[str, list[dict]] = {}
        for step in self.steps:
            input_model, output_model = step.get_inputs_outputs()
            output_fields = OutputField.create(step.id, output_model)
            # mypy has trouble seeing that its bound to KebabModel
            self_args = cast(KebabModel, step.args)
            all_args = self_args.model_dump() if self_args else {}
            issues = verify_inputs(input_model, all_args, available_outputs)
            if issues:
                all_issues[step.id] = [
                    iss.model_dump(exclude_none=True)
                    for iss in issues
                ]
            for name, field in output_fields.items():
                short_name = field.short_ref_name
                available_outputs[short_name] = field.full_ref_name
                if field.is_export:
                    outputs[name] = field.get_metadata_output()
        if all_issues:
            return issue('step_inputs_failure', context=all_issues)
        return Good((outputs, available_outputs))

gather_outputs()

Obtain a tuple with the action outputs and all steps outputs.

The steps outputs is a dictionary that maps keys of the form [step_id].[step_output_arg] to the output of another step, action input or some other value that should be used as input.

This function validates that all the keys are valid.

Returns:

Type Description
Res[ActionOutputs]

A tuple with the outputs if successful, otherwise an issue.

Source code in m/github/actions/actions.py
def gather_outputs(self: 'Action') -> Res[ActionOutputs]:
    """Obtain a tuple with the action outputs and all steps outputs.

    The steps outputs is a dictionary that maps keys of the form
    [step_id].[step_output_arg] to the output of another step, action
    input or some other value that should be used as input.

    This function validates that all the keys are valid.

    Returns:
        A tuple with the outputs if successful, otherwise an issue.
    """
    action_inputs = self.inputs or KebabModel
    available_outputs: dict[str, str] = {
        f'inputs.{name}': f'inputs.{arg_info.alias}'
        for name, arg_info in action_inputs.model_fields.items()
    }

    outputs: dict[str, MetadataOutput] = {}
    all_issues: dict[str, list[dict]] = {}
    for step in self.steps:
        input_model, output_model = step.get_inputs_outputs()
        output_fields = OutputField.create(step.id, output_model)
        # mypy has trouble seeing that its bound to KebabModel
        self_args = cast(KebabModel, step.args)
        all_args = self_args.model_dump() if self_args else {}
        issues = verify_inputs(input_model, all_args, available_outputs)
        if issues:
            all_issues[step.id] = [
                iss.model_dump(exclude_none=True)
                for iss in issues
            ]
        for name, field in output_fields.items():
            short_name = field.short_ref_name
            available_outputs[short_name] = field.full_ref_name
            if field.is_export:
                outputs[name] = field.get_metadata_output()
    if all_issues:
        return issue('step_inputs_failure', context=all_issues)
    return Good((outputs, available_outputs))

KebabModel

Bases: BaseModel

Allows models to be defined with kebab case properties.

Inputs and outputs need to be written using a KebabModel as a base class. This is so that their definitions may be written using kebab casing in the final action.yaml.

from m.github.actions import KebabModel, InArg

class MyInput(KebabModel):
    my_input: str = InArg(help='description')
Source code in m/pydantic.py
class KebabModel(BaseModel):
    """Allows models to be defined with kebab case properties.

    Inputs and outputs need to be written using a `KebabModel` as a base class.
    This is so that their definitions may be written using kebab casing in the
    final `action.yaml`.

    ```python
    from m.github.actions import KebabModel, InArg

    class MyInput(KebabModel):
        my_input: str = InArg(help='description')
    ```
    """

    model_config = ConfigDict(
        alias_generator=to_kebab,
        populate_by_name=True,
    )

RunStep

Bases: BaseModel, Generic[InputModel, OutputModel]

Model used to define a "run" step in a Github action.

The id is important because this is how we will be able to refer to the outputs generated by the step. Say our action called other external actions such as the cache action. Then if we wanted to pass one of the outputs to the cache action we would have to get a handle on the step.

args=SomeInput(some_arg='my_run_step_id.some_output')

The args should leverage the help of their own input models. All they require is that we provide the handle to other outputs from other steps or some other values we wish to pass.

Experiment with different values to see what action.yaml generates.

Attributes:

Name Type Description
id str

The id of the step.

run_if str | None

The condition to run the step.

run Callable[[InputModel], Res[OutputModel]]

The function passed to run_action.

args InputModel | None

The arguments to pass to the run function.

Source code in m/github/actions/actions.py
class RunStep(BaseModel, Generic[InputModel, OutputModel]):
    """Model used to define a "run" step in a Github action.

    The `id` is important because this is how we will be able to refer to the
    outputs generated by the step. Say our action called other external actions
    such as the cache action. Then if we wanted to pass one of the outputs to
    the cache action we would have to get a handle on the step.

    ```python
    args=SomeInput(some_arg='my_run_step_id.some_output')
    ```

    The `args` should leverage the help of their own input models. All they
    require is that we provide the handle to other outputs from other steps or
    some other values we wish to pass.

    Experiment with different values to see what `action.yaml` generates.
    """

    id: str = Field(description='The id of the step.')

    run_if: str | None = Field(
        default=None,
        description='The condition to run the step.',
    )

    run: Callable[[InputModel], Res[OutputModel]] = Field(
        description='The function passed to [`run_action`][m.github.actions.api.run_action].',
    )

    args: InputModel | None = Field(
        description='The arguments to pass to the run function.',
    )

    def get_inputs_outputs(self: 'RunStep') -> InputOutputs:
        """Get the inputs and outputs for the step.

        Returns:
            A tuple of the inputs and outputs.
        """
        return get_inputs_outputs(self.run)

    def to_str(
        self: 'RunStep',
        python_path: str,
        available_values: dict[str, str],
    ) -> str:
        """Generate a string to use in the Github Action.

        Args:
            python_path: The path to the python module.
            available_values: The values that are available to the step.

        Returns:
            A string to add to the Github action.
        """
        template = """\
            - id: {id}{run_if}
              shell: bash{env}
              run: PYTHONPATH="$GITHUB_ACTION_PATH{py_path}" python -m {mod}
        """
        run_if = _run_if(self.run_if, available_values)
        # mypy has trouble seeing that its bound to KebabModel
        self_args = cast(KebabModel, self.args)
        all_args = self_args.model_dump() if self_args else {}
        mapped_args = map_args(all_args, available_values, input_env)
        env = ''
        if mapped_args:
            arg_lines = '\n'.join([
                f'    {key}: {env_val}'
                for key, env_val in mapped_args.items()
            ])
            env = f'\n  env:\n{arg_lines}'
        py_path = f'/{python_path}' if python_path else ''
        return dedent(template).format(
            id=self.id,
            env=env,
            run_if=run_if,
            py_path=py_path,
            mod=self.run.__module__,
        ).rstrip()

get_inputs_outputs()

Get the inputs and outputs for the step.

Returns:

Type Description
InputOutputs

A tuple of the inputs and outputs.

Source code in m/github/actions/actions.py
def get_inputs_outputs(self: 'RunStep') -> InputOutputs:
    """Get the inputs and outputs for the step.

    Returns:
        A tuple of the inputs and outputs.
    """
    return get_inputs_outputs(self.run)

to_str(python_path, available_values)

Generate a string to use in the Github Action.

Parameters:

Name Type Description Default
python_path str

The path to the python module.

required
available_values dict[str, str]

The values that are available to the step.

required

Returns:

Type Description
str

A string to add to the Github action.

Source code in m/github/actions/actions.py
def to_str(
    self: 'RunStep',
    python_path: str,
    available_values: dict[str, str],
) -> str:
    """Generate a string to use in the Github Action.

    Args:
        python_path: The path to the python module.
        available_values: The values that are available to the step.

    Returns:
        A string to add to the Github action.
    """
    template = """\
        - id: {id}{run_if}
          shell: bash{env}
          run: PYTHONPATH="$GITHUB_ACTION_PATH{py_path}" python -m {mod}
    """
    run_if = _run_if(self.run_if, available_values)
    # mypy has trouble seeing that its bound to KebabModel
    self_args = cast(KebabModel, self.args)
    all_args = self_args.model_dump() if self_args else {}
    mapped_args = map_args(all_args, available_values, input_env)
    env = ''
    if mapped_args:
        arg_lines = '\n'.join([
            f'    {key}: {env_val}'
            for key, env_val in mapped_args.items()
        ])
        env = f'\n  env:\n{arg_lines}'
    py_path = f'/{python_path}' if python_path else ''
    return dedent(template).format(
        id=self.id,
        env=env,
        run_if=run_if,
        py_path=py_path,
        mod=self.run.__module__,
    ).rstrip()

UsesStep

Bases: BaseModel, Generic[InputModel, OutputModel]

A "uses" step in a Github action.

Model similar to RunStep but since we do not have access to the code that gets executed all we can do is provide the uses field and our models to describe what the action expects.

For instance, say we wanted to use the actions/cache@v4 action. To avoid having issues in the future we should create the input and output models manually by looking at the documentation https://github.com/actions/cache#inputs.

class CacheInputs(KebabModel):
    key: str = InArg(help='An explicit key for a cache entry')
    path: str = InArg(help="""
        A list of files, directories, and wildcard patterns to cache and
        restore.
    """)

Similarly for the outputs we can define a model. This in the long run should help us maintain the composite action better. It is recommended to check the inputs and outputs as we update action versions to ensure compatibility.

Attributes:

Name Type Description
id str

The step id.

run_if str | None

The condition to run the step.

uses str

A string referencing an action.

inputs type[InputModel]

A reference to a KebabModel type.

outputs type[OutputModel]

A reference to a KebabModel type.

args InputModel | None

The arguments to pass to the action

Source code in m/github/actions/actions.py
class UsesStep(BaseModel, Generic[InputModel, OutputModel]):
    """A "uses" step in a Github action.

    Model similar to [`RunStep`][m.github.actions.RunStep] but since we do
    not have access to the code that gets executed all we can do is provide the
    `uses` field and our models to describe what the action expects.

    For instance, say we wanted to use the `actions/cache@v4` action. To avoid
    having issues in the future we should create the input and output models
    manually by looking at the documentation
    <https://github.com/actions/cache#inputs>.

    ```python
    class CacheInputs(KebabModel):
        key: str = InArg(help='An explicit key for a cache entry')
        path: str = InArg(help=\"\"\"
            A list of files, directories, and wildcard patterns to cache and
            restore.
        \"\"\")
    ```

    Similarly for the outputs we can define a model. This in the long run should
    help us maintain the composite action better. It is recommended to check the
    inputs and outputs as we update action versions to ensure compatibility.
    """  # noqa: D301, D300 - Angry with slash but we need to escape them

    id: str = Field(description='The step id.')

    run_if: str | None = Field(
        default=None,
        description='The condition to run the step.',
    )

    uses: str = Field(description='A string referencing an action.')

    inputs: type[InputModel] = Field(
        description='A reference to a [KebabModel][m.pydantic.KebabModel] type.',
    )

    outputs: type[OutputModel] = Field(
        description='A reference to a [KebabModel][m.pydantic.KebabModel] type.',
    )

    args: InputModel | None = Field(
        description='The arguments to pass to the action',
    )

    def get_inputs_outputs(self: 'UsesStep') -> InputOutputs:
        """Get the inputs and outputs for the step.

        Returns:
            A tuple of the inputs and outputs.
        """
        return self.inputs, self.outputs

    def to_str(
        self: 'UsesStep',
        _python_path: str,
        available_values: dict[str, str],
    ) -> str:
        """Generate a string to use in the Github Action.

        Args:
            _python_path: The path to the python module.
            available_values: The values that are available to the step.

        Returns:
            A string to add to the Github action.
        """
        template = """\
            - id: {id}{run_if}
              uses: {uses}{env}
        """
        run_if = _run_if(self.run_if, available_values)
        # mypy has trouble seeing that its bound to KebabModel
        self_args = cast(KebabModel, self.args)
        all_args = self_args.model_dump() if self_args else {}
        mapped_args = map_args(
            all_args,
            available_values,
            lambda x: x.replace('_', '-', -1),
        )
        env = ''
        if mapped_args:
            arg_lines = '\n'.join([
                f'    {key}: {env_val}'
                for key, env_val in mapped_args.items()
            ])
            env = f'\n  with:\n{arg_lines}'
        return dedent(template).format(
            id=self.id,
            run_if=run_if,
            uses=self.uses,
            env=env,
        ).rstrip()

get_inputs_outputs()

Get the inputs and outputs for the step.

Returns:

Type Description
InputOutputs

A tuple of the inputs and outputs.

Source code in m/github/actions/actions.py
def get_inputs_outputs(self: 'UsesStep') -> InputOutputs:
    """Get the inputs and outputs for the step.

    Returns:
        A tuple of the inputs and outputs.
    """
    return self.inputs, self.outputs

to_str(_python_path, available_values)

Generate a string to use in the Github Action.

Parameters:

Name Type Description Default
_python_path str

The path to the python module.

required
available_values dict[str, str]

The values that are available to the step.

required

Returns:

Type Description
str

A string to add to the Github action.

Source code in m/github/actions/actions.py
def to_str(
    self: 'UsesStep',
    _python_path: str,
    available_values: dict[str, str],
) -> str:
    """Generate a string to use in the Github Action.

    Args:
        _python_path: The path to the python module.
        available_values: The values that are available to the step.

    Returns:
        A string to add to the Github action.
    """
    template = """\
        - id: {id}{run_if}
          uses: {uses}{env}
    """
    run_if = _run_if(self.run_if, available_values)
    # mypy has trouble seeing that its bound to KebabModel
    self_args = cast(KebabModel, self.args)
    all_args = self_args.model_dump() if self_args else {}
    mapped_args = map_args(
        all_args,
        available_values,
        lambda x: x.replace('_', '-', -1),
    )
    env = ''
    if mapped_args:
        arg_lines = '\n'.join([
            f'    {key}: {env_val}'
            for key, env_val in mapped_args.items()
        ])
        env = f'\n  with:\n{arg_lines}'
    return dedent(template).format(
        id=self.id,
        run_if=run_if,
        uses=self.uses,
        env=env,
    ).rstrip()

InArg(*, help, default=None)

Force proper annotation of the input of a GitHub Action.

Should be used to declare the input arguments of an action. It returns Any to bypass mypy's type checking. Similar to pydantic.fields.Field but it is tailored to help us write the inputs for an action and its steps.

Note

By default all input arguments are required. If you want to make an input not required then provide a default value.

Parameters:

Name Type Description Default
help str

Human-readable description.

required
default str | None

The default value for the argument.

None

Returns:

Type Description
Any

A new FieldInfo, the return annotation is Any so InArg can be used on type annotated fields without causing typing errors.

Source code in m/github/actions/api.py
def InArg(  # noqa: N802
    *,
    help: str,  # noqa: WPS125
    default: str | None = None,
) -> Any:
    """Force proper annotation of the input of a GitHub Action.

    Should be used to declare the input arguments of an action. It returns
    [`Any`][typing.Any] to bypass `mypy`'s type checking. Similar to
    [pydantic.fields.Field][] but it is tailored to help us write the inputs for
    an action and its steps.

    !!! note

        By default all input arguments are required. If you want to make an
        input not required then provide a default value.

    Args:
        help: Human-readable description.
        default: The default value for the argument.

    Returns:
        A new [`FieldInfo`][pydantic.fields.FieldInfo], the return annotation is
            `Any` so `InArg` can be used on type annotated fields without
            causing typing errors.
    """
    args = {
        'description': help,
    }
    if default is not None:
        args['default'] = default
    return FieldInfo.from_field(**args)

OutArg(*, help, export=False)

Force proper annotation of the output of a GitHub Action.

Note

All steps have access to the steps output, if we want to make the output available to the action we need to export it.

Parameters:

Name Type Description Default
help str

Human-readable description.

required
export bool

Whether the argument is to be exported to the action.

False

Returns:

Type Description
Any

A new FieldInfo, the return annotation is Any so Arg can be used on type annotated fields without causing a typing error.

Source code in m/github/actions/api.py
def OutArg(  # noqa: N802
    *,
    help: str,  # noqa: WPS125
    export: bool = False,
) -> Any:
    """Force proper annotation of the output of a GitHub Action.

    !!! note
        All steps have access to the steps output, if we want to make the output
        available to the action we need to `export` it.

    Args:
        help: Human-readable description.
        export: Whether the argument is to be exported to the action.

    Returns:
        A new [`FieldInfo`][pydantic.fields.FieldInfo], the return annotation is
            `Any` so `Arg` can be used on type annotated fields without causing
            a typing error.
    """
    return FieldInfo.from_field(
        description=help,
        json_schema_extra={'export': export},
    )

run_action(main)

Entry point for a GitHub Action.

This is the main function that should be used to run an action. It takes in a function that takes in a m.pydantic.KebabModel and returns a Res[KebabModel].

The only place where this function is needed is in the the if block

if __name__ == '__main__':
    run_action(my_action)

mypy will make sure that the you are providing the correct type of function to run_action. Keep in mind, the function is generic and we should be writing models for the inputs and outputs for all of our functions.

Parameters:

Name Type Description Default
main Callable[[InputModel], Res[OutputModel]]

The main function of the GitHub Action.

required
Source code in m/github/actions/api.py
def run_action(main: Callable[[InputModel], Res[OutputModel]]) -> None:
    """Entry point for a GitHub Action.

    This is the main function that should be used to run an action. It takes in
    a function that takes in a [m.pydantic.KebabModel][] and returns a
    `Res[KebabModel]`.

    The only place where this function is needed is in the the if block

    ```python
    if __name__ == '__main__':
        run_action(my_action)
    ```

    `mypy` will make sure that the you are providing the correct type of function
    to `run_action`. Keep in mind, the function is generic and we should be writing
    models for the inputs and outputs for all of our functions.

    Args:
        main: The main function of the GitHub Action.
    """
    logging_config()
    main_params = signature(main).parameters
    arg_name = next(iter(main_params))
    input_model = main_params[arg_name].annotation
    args = load_step_inputs(input_model)
    if isinstance(args, Bad):
        default_issue_handler(args.value)
        sys.exit(4)
    inputs = args.value
    exit_code = run_main(lambda: main(inputs), result_handler=_result_handler)
    sys.exit(exit_code)