feat: use jinja

This commit is contained in:
2026-08-05 11:00:45 +03:00
parent 705ff503d0
commit d062447e6d
21 changed files with 204 additions and 376 deletions
+29 -9
View File
@@ -1,16 +1,36 @@
[presets.docker]
conditions = ["file(Dockerfile)"]
conditions = ["{{ 'Dockerfile' | file }}"]
variables.docker_name = "{{ 'pyproject.toml' | read | get('project.name') or 'package.json' | read | get('name') }}"
variables.docker_version = "{{ 'pyproject.toml' | read | get('project.version') or 'package.json' | read | get('version') }}"
variables.docker_repo = "{{ '.env' | read | get('DOCKER_REPO') }}"
variables.docker_group = "{{ '.env' | read | get('DOCKER_GROUP') }}"
variables.docker_image = "{{ docker_group }}/{{ docker_name }}"
variables.docker_tag = "{{ '.env' | read | get('DOCKER_TAG') or 'latest' }}"
# build
[presets.docker.tasks.build.default]
variables.docker_target = "file(.env) | get(DOCKER_PROJECTS)"
variables.docker_name = "file(pyproject.toml) | get(project.name)"
variables.docker_group = "file(.env) | get(DOCKER_GROUP)"
commands = ["docker build -t {{ docker_image }} . -f Dockerfile"]
# publish version
[presets.docker.tasks.publish.version]
conditions = ["{{ docker_version }}"]
commands = [
# "docker build --target {docker_target} -t {docker_group}/{docker_name} . -f Dockerfile",
"docker build -t {docker_group}/{docker_name} . -f Dockerfile",
"docker tag {{ docker_image }} {{ docker_repo }}/{{ docker_image }}:{{ docker_version }}",
"docker push {{ docker_repo }}/{{ docker_image }}:{{ docker_version }}",
]
# publish
[presets.docker.tasks.publish.default]
commands = ["docker publish"]
# publish tag
[presets.docker.tasks.publish.tag]
conditions = ["{{ docker_tag }}"]
commands = [
"docker tag {{ docker_image }} {{ docker_repo }}/{{ docker_image }}:{{ docker_tag }}",
"docker push {{ docker_repo }}/{{ docker_image }}:{{ docker_tag }}",
]
# save
[presets.docker.tasks.save.default]
variables.docker_output = "./dist"
commands = [
"mkdir -p {{ docker_output }}",
"docker save --output {{ docker_output }}/{{ docker_name }}-{{ docker_version }}.tar {{ docker_image }}",
]
+7 -7
View File
@@ -1,26 +1,26 @@
[presets.nodejs]
conditions = ["file(package.json)"]
conditions = ["{{ 'package.json' | file }}"]
[presets.nodejs.tasks.setup.npm]
conditions = ["file(package-lock.json)"]
conditions = ["{{ 'package-lock.json' | file }}"]
commands = ["npm install-clean"]
[presets.nodejs.tasks.setup.npm-legacy]
conditions = ["file(package-lock.json) | not"]
conditions = ["{{ not ('package-lock.json' | file) }}"]
commands = ["npm install"]
[presets.nodejs.tasks.test.jest]
conditions = ["file(node_modules/jest)"]
conditions = ["{{ 'node_modules/jest' | file }}"]
commands = ["npx jest"]
[presets.nodejs.tasks.build.npm]
conditions = ["file(package.json) | get(scripts.build)"]
conditions = ["{{ 'package.json' | read | get('scripts.build') }}"]
commands = ["npm run build"]
[presets.nodejs.tasks.publish.npm]
conditions = ["file(package.json) | get(scripts.publish)"]
conditions = ["{{ 'package.json' | read | get('scripts.publish') }}"]
commands = ["npm run publish"]
[presets.nodejs.tasks.clean.node_modules]
conditions = ["file(node_modules)"]
conditions = ["{{ 'node_modules' | file }}"]
commands = ["rm -rf node_modules"]
+17 -23
View File
@@ -1,61 +1,55 @@
[presets.python]
conditions = ["file(pyproject.toml)"]
conditions = ["{{ 'pyproject.toml' | file }}"]
variables.python_poetry = "{{ 'pyproject.toml' | read | get('build-system.build-backend') == 'poetry.core.masonry.api' }}"
# setup
[presets.python.tasks.setup.poetry]
conditions = [
"file(pyproject.toml) | get(build-system.build-backend) | equal(poetry.core.masonry.api)",
]
variables.python_version = "file(pyproject.toml) | get(project.requires-python) | search((\\d+.\\d+))"
commands = [
"poetry env use {python_version}",
"poetry install",
]
conditions = ["{{ python_poetry }}"]
variables.python_version = "{{ 'pyproject.toml' | read | get('project.requires-python') | search('(\\d+.\\d+)') }}"
commands = ["poetry env use {{ python_version }}", "poetry install"]
# build
[presets.python.tasks.build.poetry]
conditions = [
"file(pyproject.toml) | get(build-system.build-backend) | equal(poetry.core.masonry.api)",
]
conditions = ["{{ python_poetry }}"]
commands = ["poetry build"]
# publish
[presets.python.tasks.publish.poetry]
conditions = [
"file(pyproject.toml) | get(build-system.build-backend) | equal(poetry.core.masonry.api)",
"file(poetry.toml) | get(repositories)",
"{{ python_poetry }}",
"{{ 'poetry.toml' | read | get('repositories') }}",
]
variables.python_repository = "file(poetry.toml) | get(repositories) | key(0)"
commands = ["poetry publish -r {python_repository}"]
variables.python_repository = "{{ 'poetry.toml' | read | get('repositories') | key(0) }}"
commands = ["poetry publish -r {{ python_repository }}"]
# test
[presets.python.tasks.test.pytest]
conditions = ["file(.venv/bin/pytest)"]
conditions = ["{{ '.venv/bin/pytest' | file }}"]
commands = [".venv/bin/python -m pytest"]
# lint
[presets.python.tasks.lint.pylint]
conditions = ["file(.venv/bin/pylint)"]
conditions = ["{{ '.venv/bin/pylint' | file }}"]
commands = [".venv/bin/pylint ./"]
[presets.python.tasks.lint.isort]
conditions = ["file(.venv/bin/isort)"]
conditions = ["{{ '.venv/bin/isort' | file }}"]
commands = [".venv/bin/isort --profile black --check-only ./"]
[presets.python.tasks.lint.black]
conditions = ["file(.venv/bin/black)"]
conditions = ["{{ '.venv/bin/black' | file }}"]
commands = [".venv/bin/black --check --diff ./"]
# format
[presets.python.tasks.format.isort]
conditions = ["file(.venv/bin/isort)"]
conditions = ["{{ '.venv/bin/isort' | file }}"]
commands = [".venv/bin/isort --profile black ./"]
[presets.python.tasks.format.black]
conditions = ["file(.venv/bin/black)"]
conditions = ["{{ '.venv/bin/isort' | file }}"]
commands = [".venv/bin/black ./"]
# clean
[presets.python.tasks.clean.venv]
conditions = ["file(.venv)"]
conditions = ["{{ '.venv' | file }}"]
commands = ["rm -rf .venv"]
+122
View File
@@ -0,0 +1,122 @@
import json
import re
import tomllib
from collections import defaultdict
from pathlib import Path
import dotenv
from jinja2 import Environment
from ..entry import Entry, EntryDetector, TaskKey
from .loader import PresetLoader, PresetLoaderConfig
from .model import Variables
def _build_env(basepath: Path) -> Environment:
env = Environment()
def do_file(filename: str) -> Path | None:
result = Path(filename)
if not result.is_absolute():
result = basepath / result
if result.exists():
return result
return None
def do_read(filename: str) -> dict | None:
path = do_file(filename)
if path:
if path.suffix == ".toml":
return tomllib.loads(path.read_text())
if path.suffix == ".json":
return json.loads(path.read_text())
if path.suffix == ".env" or path.name == ".env":
return dotenv.dotenv_values(path)
return None
def do_get(data: dict | None, keypath: str) -> str | None:
if not data:
return None
path = keypath.split(".")
result = data.get(path[0])
for key in path[1:]:
if result:
result = result.get(key)
return result
def do_search(data: str | None, pattern: str, position: int = 1) -> str | None:
if not data:
return None
matched = re.compile(pattern).search(data)
if matched:
return matched.group(position)
return None
def do_key(data: dict | None, keyindex: int = 0) -> str | None:
if not data:
return None
keys = list(data.keys())
return keys[keyindex]
env.filters["file"] = do_file
env.filters["read"] = do_read
env.filters["get"] = do_get
env.filters["search"] = do_search
env.filters["key"] = do_key
return env
class PathProcessor:
def __init__(self, basepath: Path):
self._basepath = basepath
self._env = _build_env(basepath)
def update_variables(self, templates: Variables, variables: Variables):
for k, v in templates.items():
variables[k] = self._env.from_string(v).render(**variables)
def fill_template(self, template: str, variables: Variables) -> str:
return self._env.from_string(template).render(**variables)
def check_conditions(self, templates: list[str], variables: Variables) -> bool:
for template in templates:
value = self.fill_template(template, variables)
if value.lower() in ["false", "null", "none", "0", ""]:
return False
return True
class PresetEntryDetector(EntryDetector):
def __init__(self, preset_loader_config: PresetLoaderConfig | None = None):
self._preset_loader_config = preset_loader_config
def detect_entry(self, basepath: Path) -> Entry | None:
processor = PathProcessor(basepath)
preset = PresetLoader(basepath, self._preset_loader_config).load()
if preset.skip:
return None
tasks = defaultdict(list)
for tag, preset in preset.presets.items():
preset_variables: Variables = {"entry_path": basepath}
if preset.variables:
processor.update_variables(preset.variables, preset_variables)
if preset.conditions and not processor.check_conditions(
preset.conditions, preset_variables
):
continue
for task_name, task_pack in preset.tasks.items():
for task in task_pack.values():
if not task.conditions or processor.check_conditions(
task.conditions, preset_variables
):
variables = preset_variables.copy()
if task.variables:
processor.update_variables(task.variables, variables)
tasks[TaskKey(task_name, tag)] += [
processor.fill_template(command, variables)
for command in task.commands
]
if tasks:
return Entry(tasks, basepath)
return None
-45
View File
@@ -1,45 +0,0 @@
from collections import defaultdict
from pathlib import Path
from ..entry import Entry, EntryDetector, TaskKey
from ..pipe.core import PipeParser, PipeRunner
from .resolve import PresetResolver, PresetResolverConfig
__all__ = ("PresetEntryDetector",)
class PresetEntryDetector(EntryDetector):
_PIPE_PARSER = PipeParser()
def __init__(self, preset_resolver_config: PresetResolverConfig | None = None):
self._pipeparser = PipeParser()
self._preset_resolver_config = preset_resolver_config
def detect_entry(self, basepath: Path) -> Entry | None:
preset = PresetResolver(basepath, self._preset_resolver_config).resolve_preset()
if preset.skip:
return None
piperunner = PipeRunner(basepath)
tasks = defaultdict(list)
for tag, preset in preset.presets.items():
if not piperunner.check_conditions(preset.conditions):
continue
for task_name, task_pack in preset.tasks.items():
for task in task_pack.values():
if piperunner.check_conditions(task.conditions):
variables = {
"entry_path": basepath,
}
if task.variables:
variables.update(
{
k: piperunner.resolve_value(v)
for k, v in task.variables.items()
}
)
tasks[TaskKey(task_name, tag)] += [
command.format(**variables) for command in task.commands
]
if tasks:
return Entry(tasks, basepath)
return None
@@ -1,33 +1,27 @@
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import tomli
from ..util import merge_dicts
from .model import Preset
__all__ = (
"PresetResolverConfig",
"PresetResolver",
)
@dataclass
class PresetResolverConfig:
class PresetLoaderConfig:
default: bool = True
user: bool = True
project: bool = True
class PresetResolver:
class PresetLoader:
_source_cache: dict[Path, dict] = {}
def __init__(self, basepath: Path, config: PresetResolverConfig | None = None):
def __init__(self, basepath: Path, config: PresetLoaderConfig | None = None):
self._basepath = basepath
self._config = config or PresetResolverConfig()
self._config = config or PresetLoaderConfig()
def iter_sources(self) -> Iterable[Path]:
def _iter_sources(self) -> Iterable[Path]:
if self._config.default:
yield from (Path(__file__).parent / "default").iterdir()
if self._config.user:
@@ -44,11 +38,11 @@ class PresetResolver:
break
projectpath = projectpath.parent
def resolve_preset(self) -> Preset:
def load(self) -> Preset:
result_preset_data = {}
for filepath in self.iter_sources():
for filepath in self._iter_sources():
if filepath not in self._source_cache:
with open(filepath, "rb") as fd:
self._source_cache[filepath] = tomli.load(fd)
self._source_cache[filepath] = tomllib.load(fd)
merge_dicts(result_preset_data, self._source_cache[filepath])
return Preset(**result_preset_data)
+3 -6
View File
@@ -1,20 +1,17 @@
from pydantic import BaseModel
__all__ = (
"TaskPreset",
"EntryPreset",
"Preset",
)
Variables = dict[str, str]
class TaskPreset(BaseModel):
conditions: list[str] | None = None
variables: dict[str, str] | None = None
variables: Variables | None = None
commands: list[str]
class EntryPreset(BaseModel):
conditions: list[str] | None = None
variables: Variables | None = None
tasks: dict[str, dict[str, TaskPreset]]