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
+2 -1
View File
@@ -8,7 +8,8 @@ disable=
relative-beyond-top-level, relative-beyond-top-level,
subprocess-run-check, subprocess-run-check,
bare-except, bare-except,
fixme fixme,
unspecified-encoding
[FORMAT] [FORMAT]
max-line-length=120 max-line-length=120
-7
View File
@@ -1,10 +1,3 @@
__all__ = (
"CommandError",
"CommandLogger",
"CommandExecutor",
)
import logging import logging
import os import os
import subprocess import subprocess
-7
View File
@@ -4,13 +4,6 @@ from typing import Iterable, NamedTuple
from .common.command import CommandExecutor from .common.command import CommandExecutor
__all__ = (
"TaskKey",
"Entry",
"EntryDetector",
"EntryResolver",
)
logger = logging.getLogger("entry") logger = logging.getLogger("entry")
+3 -4
View File
@@ -5,15 +5,14 @@ from typing import Iterable
from .common.command import CommandError from .common.command import CommandError
from .entry import Entry, EntryResolver from .entry import Entry, EntryResolver
from .preset.entry import PresetEntryDetector from .preset.detector import PresetEntryDetector
__all__ = ("ProjectManager",)
logger = logging.getLogger("project") logger = logging.getLogger("project")
class ProjectManager: class ProjectManager:
DEFAULT_IGNORE = ["node_modules", ".venv", ".git", "tests"] # TODO: from .gitignore?
DEFAULT_IGNORE = ["node_modules", ".venv", ".git", "tests", "__pycache__"]
_logger = logger _logger = logger
def __init__(self, basepath: Path): 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] [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 # build
[presets.docker.tasks.build.default] [presets.docker.tasks.build.default]
variables.docker_target = "file(.env) | get(DOCKER_PROJECTS)" commands = ["docker build -t {{ docker_image }} . -f Dockerfile"]
variables.docker_name = "file(pyproject.toml) | get(project.name)"
variables.docker_group = "file(.env) | get(DOCKER_GROUP)" # publish version
[presets.docker.tasks.publish.version]
conditions = ["{{ docker_version }}"]
commands = [ commands = [
# "docker build --target {docker_target} -t {docker_group}/{docker_name} . -f Dockerfile", "docker tag {{ docker_image }} {{ docker_repo }}/{{ docker_image }}:{{ docker_version }}",
"docker build -t {docker_group}/{docker_name} . -f Dockerfile", "docker push {{ docker_repo }}/{{ docker_image }}:{{ docker_version }}",
] ]
# publish # publish tag
[presets.docker.tasks.publish.default] [presets.docker.tasks.publish.tag]
commands = ["docker publish"] 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] [presets.nodejs]
conditions = ["file(package.json)"] conditions = ["{{ 'package.json' | file }}"]
[presets.nodejs.tasks.setup.npm] [presets.nodejs.tasks.setup.npm]
conditions = ["file(package-lock.json)"] conditions = ["{{ 'package-lock.json' | file }}"]
commands = ["npm install-clean"] commands = ["npm install-clean"]
[presets.nodejs.tasks.setup.npm-legacy] [presets.nodejs.tasks.setup.npm-legacy]
conditions = ["file(package-lock.json) | not"] conditions = ["{{ not ('package-lock.json' | file) }}"]
commands = ["npm install"] commands = ["npm install"]
[presets.nodejs.tasks.test.jest] [presets.nodejs.tasks.test.jest]
conditions = ["file(node_modules/jest)"] conditions = ["{{ 'node_modules/jest' | file }}"]
commands = ["npx jest"] commands = ["npx jest"]
[presets.nodejs.tasks.build.npm] [presets.nodejs.tasks.build.npm]
conditions = ["file(package.json) | get(scripts.build)"] conditions = ["{{ 'package.json' | read | get('scripts.build') }}"]
commands = ["npm run build"] commands = ["npm run build"]
[presets.nodejs.tasks.publish.npm] [presets.nodejs.tasks.publish.npm]
conditions = ["file(package.json) | get(scripts.publish)"] conditions = ["{{ 'package.json' | read | get('scripts.publish') }}"]
commands = ["npm run publish"] commands = ["npm run publish"]
[presets.nodejs.tasks.clean.node_modules] [presets.nodejs.tasks.clean.node_modules]
conditions = ["file(node_modules)"] conditions = ["{{ 'node_modules' | file }}"]
commands = ["rm -rf node_modules"] commands = ["rm -rf node_modules"]
+17 -23
View File
@@ -1,61 +1,55 @@
[presets.python] [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 # setup
[presets.python.tasks.setup.poetry] [presets.python.tasks.setup.poetry]
conditions = [ conditions = ["{{ python_poetry }}"]
"file(pyproject.toml) | get(build-system.build-backend) | equal(poetry.core.masonry.api)", variables.python_version = "{{ 'pyproject.toml' | read | get('project.requires-python') | search('(\\d+.\\d+)') }}"
] commands = ["poetry env use {{ python_version }}", "poetry install"]
variables.python_version = "file(pyproject.toml) | get(project.requires-python) | search((\\d+.\\d+))"
commands = [
"poetry env use {python_version}",
"poetry install",
]
# build # build
[presets.python.tasks.build.poetry] [presets.python.tasks.build.poetry]
conditions = [ conditions = ["{{ python_poetry }}"]
"file(pyproject.toml) | get(build-system.build-backend) | equal(poetry.core.masonry.api)",
]
commands = ["poetry build"] commands = ["poetry build"]
# publish # publish
[presets.python.tasks.publish.poetry] [presets.python.tasks.publish.poetry]
conditions = [ conditions = [
"file(pyproject.toml) | get(build-system.build-backend) | equal(poetry.core.masonry.api)", "{{ python_poetry }}",
"file(poetry.toml) | get(repositories)", "{{ 'poetry.toml' | read | get('repositories') }}",
] ]
variables.python_repository = "file(poetry.toml) | get(repositories) | key(0)" variables.python_repository = "{{ 'poetry.toml' | read | get('repositories') | key(0) }}"
commands = ["poetry publish -r {python_repository}"] commands = ["poetry publish -r {{ python_repository }}"]
# test # test
[presets.python.tasks.test.pytest] [presets.python.tasks.test.pytest]
conditions = ["file(.venv/bin/pytest)"] conditions = ["{{ '.venv/bin/pytest' | file }}"]
commands = [".venv/bin/python -m pytest"] commands = [".venv/bin/python -m pytest"]
# lint # lint
[presets.python.tasks.lint.pylint] [presets.python.tasks.lint.pylint]
conditions = ["file(.venv/bin/pylint)"] conditions = ["{{ '.venv/bin/pylint' | file }}"]
commands = [".venv/bin/pylint ./"] commands = [".venv/bin/pylint ./"]
[presets.python.tasks.lint.isort] [presets.python.tasks.lint.isort]
conditions = ["file(.venv/bin/isort)"] conditions = ["{{ '.venv/bin/isort' | file }}"]
commands = [".venv/bin/isort --profile black --check-only ./"] commands = [".venv/bin/isort --profile black --check-only ./"]
[presets.python.tasks.lint.black] [presets.python.tasks.lint.black]
conditions = ["file(.venv/bin/black)"] conditions = ["{{ '.venv/bin/black' | file }}"]
commands = [".venv/bin/black --check --diff ./"] commands = [".venv/bin/black --check --diff ./"]
# format # format
[presets.python.tasks.format.isort] [presets.python.tasks.format.isort]
conditions = ["file(.venv/bin/isort)"] conditions = ["{{ '.venv/bin/isort' | file }}"]
commands = [".venv/bin/isort --profile black ./"] commands = [".venv/bin/isort --profile black ./"]
[presets.python.tasks.format.black] [presets.python.tasks.format.black]
conditions = ["file(.venv/bin/black)"] conditions = ["{{ '.venv/bin/isort' | file }}"]
commands = [".venv/bin/black ./"] commands = [".venv/bin/black ./"]
# clean # clean
[presets.python.tasks.clean.venv] [presets.python.tasks.clean.venv]
conditions = ["file(.venv)"] conditions = ["{{ '.venv' | file }}"]
commands = ["rm -rf .venv"] 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 dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
import tomli
from ..util import merge_dicts from ..util import merge_dicts
from .model import Preset from .model import Preset
__all__ = (
"PresetResolverConfig",
"PresetResolver",
)
@dataclass @dataclass
class PresetResolverConfig: class PresetLoaderConfig:
default: bool = True default: bool = True
user: bool = True user: bool = True
project: bool = True project: bool = True
class PresetResolver: class PresetLoader:
_source_cache: dict[Path, dict] = {} _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._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: if self._config.default:
yield from (Path(__file__).parent / "default").iterdir() yield from (Path(__file__).parent / "default").iterdir()
if self._config.user: if self._config.user:
@@ -44,11 +38,11 @@ class PresetResolver:
break break
projectpath = projectpath.parent projectpath = projectpath.parent
def resolve_preset(self) -> Preset: def load(self) -> Preset:
result_preset_data = {} result_preset_data = {}
for filepath in self.iter_sources(): for filepath in self._iter_sources():
if filepath not in self._source_cache: if filepath not in self._source_cache:
with open(filepath, "rb") as fd: 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]) merge_dicts(result_preset_data, self._source_cache[filepath])
return Preset(**result_preset_data) return Preset(**result_preset_data)
+3 -6
View File
@@ -1,20 +1,17 @@
from pydantic import BaseModel from pydantic import BaseModel
__all__ = ( Variables = dict[str, str]
"TaskPreset",
"EntryPreset",
"Preset",
)
class TaskPreset(BaseModel): class TaskPreset(BaseModel):
conditions: list[str] | None = None conditions: list[str] | None = None
variables: dict[str, str] | None = None variables: Variables | None = None
commands: list[str] commands: list[str]
class EntryPreset(BaseModel): class EntryPreset(BaseModel):
conditions: list[str] | None = None conditions: list[str] | None = None
variables: Variables | None = None
tasks: dict[str, dict[str, TaskPreset]] 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): def merge_dicts(target, enhancer):
for key, val in enhancer.items(): for key, val in enhancer.items():
if key not in target: if key not in target:
+12 -10
View File
@@ -5,21 +5,21 @@ from pathlib import Path
import pytest import pytest
from project_manager.entry import Entry, EntryDetector from project_manager.entry import Entry, EntryDetector
from project_manager.preset.entry import PresetEntryDetector from project_manager.preset.detector import PresetEntryDetector
from project_manager.preset.resolve import PresetResolverConfig from project_manager.preset.loader import PresetLoaderConfig
@pytest.fixture @pytest.fixture
def preset_resolver_config(): def preset_loader_config():
return PresetResolverConfig( return PresetLoaderConfig(
user=False, user=False,
project=False, project=False,
) )
@pytest.fixture @pytest.fixture
def entry_detector(preset_resolver_config: PresetResolverConfig): def entry_detector(preset_loader_config: PresetLoaderConfig):
return PresetEntryDetector(preset_resolver_config) return PresetEntryDetector(preset_loader_config)
@pytest.fixture @pytest.fixture
@@ -31,18 +31,20 @@ def entry(entry_detector: EntryDetector, entry_path: Path, tmp_path: Path):
@pytest.mark.parametrize("entry_path", [Path(__file__).parent / "python"]) @pytest.mark.parametrize("entry_path", [Path(__file__).parent / "python"])
def test_python_entry(entry: Entry | None, entry_detector: EntryDetector): def test_python_entry(entry: Entry | None, entry_detector: EntryDetector):
assert entry is not None assert entry is not None
assert {"setup", "build"} == entry._tasks.keys() assert {"setup", "build"} == {k.name for k in entry._tasks.keys()}
entry.run_task("setup") entry.run_task("setup")
entry = entry_detector.detect_entry(entry._basepath) entry = entry_detector.detect_entry(entry._basepath)
assert entry is not None assert entry is not None
assert {"setup", "build", "lint", "format", "test", "clean"} == entry._tasks.keys() assert {"setup", "build", "lint", "format", "test", "clean"} == {
k.name for k in entry._tasks.keys()
}
@pytest.mark.parametrize("entry_path", [Path(__file__).parent / "nodejs"]) @pytest.mark.parametrize("entry_path", [Path(__file__).parent / "nodejs"])
def test_nodejs_entry(entry: Entry | None, entry_detector: EntryDetector): def test_nodejs_entry(entry: Entry | None, entry_detector: EntryDetector):
assert entry is not None assert entry is not None
assert {"setup", "build"} == entry._tasks.keys() assert {"setup", "build"} == {k.name for k in entry._tasks.keys()}
entry.run_task("setup") entry.run_task("setup")
entry = entry_detector.detect_entry(entry._basepath) entry = entry_detector.detect_entry(entry._basepath)
assert entry is not None assert entry is not None
assert {"setup", "build", "test", "clean"} == entry._tasks.keys() assert {"setup", "build", "test", "clean"} == {k.name for k in entry._tasks.keys()}