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
-7
View File
@@ -1,10 +1,3 @@
__all__ = (
"CommandError",
"CommandLogger",
"CommandExecutor",
)
import logging
import os
import subprocess
-7
View File
@@ -4,13 +4,6 @@ from typing import Iterable, NamedTuple
from .common.command import CommandExecutor
__all__ = (
"TaskKey",
"Entry",
"EntryDetector",
"EntryResolver",
)
logger = logging.getLogger("entry")
+3 -4
View File
@@ -5,15 +5,14 @@ from typing import Iterable
from .common.command import CommandError
from .entry import Entry, EntryResolver
from .preset.entry import PresetEntryDetector
__all__ = ("ProjectManager",)
from .preset.detector import PresetEntryDetector
logger = logging.getLogger("project")
class ProjectManager:
DEFAULT_IGNORE = ["node_modules", ".venv", ".git", "tests"]
# TODO: from .gitignore?
DEFAULT_IGNORE = ["node_modules", ".venv", ".git", "tests", "__pycache__"]
_logger = logger
def __init__(self, basepath: Path):
-3
View File
@@ -1,3 +0,0 @@
from .core import PIPE_REGISTRY
PIPE_REGISTRY.register_module("project_manager.pipe.type")
-87
View File
@@ -1,87 +0,0 @@
import importlib
import re
from pathlib import Path
from typing import Any, Generic, TypeVar
__all__ = ("Pipe", "PipeRegistry", "PipeParser", "PipeRunner", "PIPE_REGISTRY")
I = TypeVar("I")
O = TypeVar("O")
class Pipe(Generic[I, O]):
type: str
def apply(self, value: I, basepath: Path) -> O | None:
raise NotImplementedError
class PipeRegistry:
def __init__(self):
self._pipes = {}
def register(self, pipe_type: type[Pipe]):
self._pipes[pipe_type.type] = pipe_type
def register_module(self, module_name: str):
module = importlib.import_module(module_name)
modulepath = Path(module.__file__).parent
# pylint:disable=too-many-nested-blocks
for item in modulepath.iterdir():
if item.suffix == ".py" and not item.name.startswith("__"):
sub_module = importlib.import_module(f"{module_name}.{item.stem}")
for v in sub_module.__dict__.values():
if isinstance(v, type):
for baseclass in v.__bases__:
if baseclass == Pipe:
self.register(v)
def get(self, pipe_type: str) -> type[Pipe]:
return self._pipes[pipe_type]
class PipeParser:
_PIPE_PATTERN = re.compile("^(\\w+)\\((.*)\\)")
def parse(self, pipeline: str) -> list[Pipe]:
chain = [item.strip() for item in pipeline.split("|")]
result = []
for part in chain:
matched = self._PIPE_PATTERN.match(part)
if matched:
pipe_name = matched.group(1)
pipe_args = [item.strip() for item in matched.group(2).split(",")]
else:
pipe_name = part.strip()
pipe_args = []
pipe_type = PIPE_REGISTRY.get(pipe_name)
result.append(pipe_type(*pipe_args))
return result
class PipeRunner:
def __init__(self, basepath: Path, parser: PipeParser | None = None):
self._basepath = basepath
self._parser = parser or PipeParser()
def run(self, pipes: list[Pipe]) -> Any:
result = None
for pipe in pipes:
result = pipe.apply(result, self._basepath)
if result is None:
break
return result
def resolve_value(self, pipeline: str) -> Any:
pipe = self._parser.parse(pipeline)
return self.run(pipe)
def check_conditions(self, conditions: list[str] | None) -> bool:
if conditions:
for condition in conditions:
if not self.resolve_value(condition):
return False
return True
PIPE_REGISTRY = PipeRegistry()
-16
View File
@@ -1,16 +0,0 @@
from pathlib import Path
from typing import Any
from ..core import Pipe
__all__ = ("EqualPipe",)
class EqualPipe(Pipe[Any, bool]):
type = "equal"
def __init__(self, value: Any):
self._value = value
def apply(self, value: Any, basepath: Path) -> bool:
return self._value == value
-54
View File
@@ -1,54 +0,0 @@
import json
from pathlib import Path
import tomllib
from typing import Any
import dotenv
from ..core import Pipe
__all__ = (
"FileData",
"FilePipe",
)
class FileData:
def __init__(self, filepath: Path, loader: str | None = None):
self._filepath = filepath
self._loader = loader
self._data = None
def _load_data(self):
if self._filepath.suffix == ".toml":
return tomllib.loads(self._filepath.read_text())
elif self._filepath.suffix == ".json":
return json.loads(self._filepath.read_text())
elif self._filepath.suffix == ".env" or self._filepath.name == ".env":
return dotenv.dotenv_values(self._filepath)
return self._filepath.read_text()
def get(self, key: str):
if self._data is None and self._filepath.exists():
self._data = self._load_data()
if self._data:
return self._data.get(key)
return None
def __bool__(self):
return self._filepath.exists()
class FilePipe(Pipe[None, Any]):
type = "file"
def __init__(self, filename: str | None, loader: str | None = None):
self._filename = filename
self._loader = loader
def apply(self, value: str | None, basepath: Path) -> Any | None:
filepath = Path(value or self._filename) # TODO: raise none filepath exception
if not filepath.is_absolute():
filepath = basepath / filepath
return FileData(filepath, self._loader)
-32
View File
@@ -1,32 +0,0 @@
from pathlib import Path
from typing import Any
from ..core import Pipe
__all__ = ("GetPipe", "KeyPipe")
class GetPipe(Pipe[dict, Any]):
type = "get"
def __init__(self, keypath: str):
self._keypath = keypath
def apply(self, value: dict, basepath: Path) -> Any | None:
path = self._keypath.split(".")
result = value.get(path[0])
for key in path[1:]:
if result:
result = result.get(key)
return result
class KeyPipe(Pipe[dict, Any]):
type = "key"
def __init__(self, keyindex: str | None):
self._keyindex = int(keyindex) if keyindex else 0
def apply(self, value: dict, basepath: Path) -> Any | None:
keys = list(value.keys())
return keys[self._keyindex]
-13
View File
@@ -1,13 +0,0 @@
from pathlib import Path
from typing import Any
from ..core import Pipe
__all__ = ("NotPipe",)
class NotPipe(Pipe[Any, bool]):
type = "not"
def apply(self, value: Any, basepath: Path) -> bool:
return not value
-20
View File
@@ -1,20 +0,0 @@
import re
from pathlib import Path
from ..core import Pipe
__all__ = ("SearchPipe",)
class SearchPipe(Pipe[str, str]):
type = "search"
def __init__(self, patter: str, position: str = "1"):
self._patter = patter
self._position = int(position)
def apply(self, value: str, basepath: Path) -> str | None:
matched = re.compile(self._patter).search(value)
if matched:
return matched.group(self._position)
return None
+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]]
-17
View File
@@ -1,20 +1,3 @@
import importlib
__all__ = (
"reflect",
"merge_dicts",
)
def reflect(value: str):
if ":" in value:
module_name, item_name = value.split(":")
else:
module_name, item_name = "builtins", value
module = importlib.import_module(module_name)
return getattr(module, item_name)
def merge_dicts(target, enhancer):
for key, val in enhancer.items():
if key not in target: