Skip to content

Mityuha/sugaru

Repository files navigation

sugaru

🍭 Your own syntax you've always been dreaming of. 🍭


Sugaru

Sugaru is a lightweight completely customizable plugin system, that gives you an opportunity to do things like:

  • Writing files followed your own syntax.
  • Translating such files to ones with any other syntax.
  • Any custom user-defined replacements (e.g. templates' replacements with environment variables).
  • Converting files from one format to another (yaml --> json, toml --> ini, etc).
  • Any other fascinating features you can imagine not listed above.

Requirements

Python 3.7+

Installation

$ pip3 install sugaru

Table of contents

Quickstart

It's better to see something once than to read documentation a thousand times. Let's follow this principle.
You are a kind of DevOps engineer. You write CI files every day. That's why you've learnt by heart some CI stages: literally, line-by-line.
One day you've fed up with copy-pasting/writing complete stages into the new project. And you have decided to reduce time and effort to write the same stage the hundredth time.
You took a close look to your stage once again:

stages:
  - tests

pytest:
  stage: "tests"
  image: "python:3.12.0"
  before_script:
    - poetry install
  script:
    - poetry run pytest

And came up with idea to simply remove it. Indeed, the presence of some python-tests stage can mean the same stage's code every time. Why not just generate such a stage then? You ended up with the syntax like:

stages: ""
python-tests: "python:3.12.0"

That's it! Looks good, doesn't it?
To translate python-tests stage to tests one let's write a couple of plugins that will do the job.
The first one will generate stages section:

from typing import Any, Dict, List


def generate_stages(section_name: str, section: Any, sections: Dict[str, Any]) -> List[str]:
    if section_name != "stages":
        return section

    known_stages: Dict[str, str] = {"python-tests": "tests"}
    try:
        return [known_stages[stage] for stage in sections if stage != "stages"]
    except KeyError as unknown_stage:
        raise ValueError(f"Unknown stage: {unknown_stage}") from None

The second plugin will generate python-tests section:

def generate_tests(section_name: str, section: Any) -> Dict[str, Any]:
    if section_name != "python-tests":
        return section

    py_image: str = section
    return {
        "stage": "tests",
        "image": py_image,
        "before_script": ["poetry install"],
        "script": ["poetry run pytest"],
    }

Let's put our plugins into the file called python_tests.py. And put our awesome yml syntax into the file called .my-gitlab-ci.yml.
To make it work simply type:

$ python3 -m sugaru .my-gitlab-ci.yml --plugin python_tests

That's it. You will see the correct .gitlab-ci.yml syntax output on your screen.

how-it-works-no-detail

This picture illustrates how sugaru works in a nutshell.

How it actually works

There are some classes under the hood that work as a pipeline:

how-it-works

There is a file path as an entry point parameter (e.g. path to `.my-gitlab-ci.yml`). Then the output of every component is the input of the next component.
  • File Loader
    • Output: file content as any JSON type
  • Section Encoder
    • Output: sections, i.e. mapping section-name: section-content.
  • Plugin Executor
    • Output: sections after every plugin execution
  • Section Decoder
    • Output: file content as any JSON type
  • File Writer
    • Output: the file with the content (including stdout)

There is also an interesting component called Object Loader. We'll discuss it later.

Preparations under the hood

There is the default implementation for every component listed above.
Instead of using default components, you can define your own ones.
If you do so, such components are loaded by Object Loader component.

components-loading

And what about the Object Loader component itself?
Actually, you can even implement a custom Object Loader component. Such the custom Object Loader will be loaded by default Object Loader first and then will replace the default one.

How objects are loaded

All custom defined objects -- including user plugins -- are loaded by interfaces.
To implement your own component you have to implement the interface dedicated to it.
For example, to implement custom Section Encoder you have to implement the following interface:

class SectionEncoder(Protocol):
    def __call__(self, content: JSON) -> Dict[SecName, Section]:
        ...

The only exception is user Plugin. A plugin interface is defined as

class Plugin(Protocol):
    def __call__(
        self,
        *,
        section: Section,
        section_name: str,
        sections: Mapping[SecName, Section],
    ) -> Section:
        ...

To implement a plugin you should define any combination of interfaces' parameters. For example the following implementation also fits:

def empty_section(section_name: str) -> Any:
    print(section_name)
    return {}

The last thing you should known about interface's implementation is that type hints are not validated by sugaru (yet). It's up to you to use type hints for your own purposes.
Take a closer look to interfaces.py file for more interfaces' detail.

Examples

You will find more examples with detail explanations inside examples folder.

Dependencies

Sugaru has some default dependencies, that are:

  • typer. Typer makes sugaru more convenient to use.
  • pyyaml. Sugaru manages to deal with yaml files by default.

You can also install loguru for more beautiful logs.

Changelog

You can see the release history here: https://github.com/Mityuha/sugaru/releases/


Sugaru is MIT licensed code.