From 265c80f0827b265e4856047cf4daebbc3813856b Mon Sep 17 00:00:00 2001 From: shmyga Date: Tue, 21 Mar 2023 20:13:18 +0300 Subject: [PATCH] feat: add preset resolver --- project_manager/default_preset.toml | 54 ---------------------- project_manager/main.py | 1 - project_manager/pipe/core.py | 9 ++-- project_manager/pipe/type/file.py | 49 ++++++++++++++------ project_manager/pipe/util.py | 13 ------ project_manager/preset/default/nodejs.toml | 18 ++++++++ project_manager/preset/default/python.toml | 33 +++++++++++++ project_manager/preset/entry.py | 38 +++++++-------- project_manager/preset/model.py | 10 ++-- project_manager/preset/resolve.py | 41 ++++++++++++++++ project_manager/preset/util.py | 15 ------ project_manager/util.py | 28 +++++++++++ 12 files changed, 183 insertions(+), 126 deletions(-) delete mode 100644 project_manager/default_preset.toml delete mode 100644 project_manager/pipe/util.py create mode 100644 project_manager/preset/default/nodejs.toml create mode 100644 project_manager/preset/default/python.toml create mode 100644 project_manager/preset/resolve.py delete mode 100644 project_manager/preset/util.py create mode 100644 project_manager/util.py diff --git a/project_manager/default_preset.toml b/project_manager/default_preset.toml deleted file mode 100644 index 7de7f26..0000000 --- a/project_manager/default_preset.toml +++ /dev/null @@ -1,54 +0,0 @@ -[presets.python] -filename = "pyproject.toml" - -[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(tool.poetry.dependencies.python) | search((\\d.\\d))" -commands = ["poetry env use {python_version}", "poetry install"] - -[presets.python.tasks.test.pytest] -conditions = [ - "file(pyproject.toml) | get(tool.poetry.group.dev.dependencies.pytest)", -] -commands = ["poetry run pytest -p no:warnings"] - -[presets.python.tasks.lint.isort] -conditions = [ - "file(pyproject.toml) | get(tool.poetry.group.dev.dependencies.isort)", -] -commands = ["poetry run isort --profile black --check-only ./"] - -[presets.python.tasks.format.isort] -conditions = [ - "file(pyproject.toml) | get(tool.poetry.group.dev.dependencies.isort)", -] -commands = ["poetry run isort ./"] - -[presets.python.tasks.lint.black] -conditions = [ - "file(pyproject.toml) | get(tool.poetry.group.dev.dependencies.black)", -] -commands = ["poetry run black --check --diff ./"] - -[presets.python.tasks.format.black] -conditions = [ - "file(pyproject.toml) | get(tool.poetry.group.dev.dependencies.black)", -] -commands = ["poetry run black ./"] - -[presets.nodejs] -filename = "package.json" - -[presets.nodejs.tasks.setup.npm] -conditions = ["file(package-lock.json)"] -commands = ["npm install-clean --legacy-peer-deps"] - -[presets.nodejs.tasks.setup.npm-legacy] -conditions = ["file(package-lock.json) | not"] -commands = ["npm install --legacy-peer-deps"] - -[presets.nodejs.tasks.test.jest] -conditions = ["file(package.json) | get(devDependencies.jest)"] -commands = ["npx jest"] diff --git a/project_manager/main.py b/project_manager/main.py index 05ec912..1d836e9 100644 --- a/project_manager/main.py +++ b/project_manager/main.py @@ -14,7 +14,6 @@ def run(): "task", nargs="?", default="show", - choices=("show", "setup", "lint", "format", "test"), ) parser.add_argument("-p", "--path", default=os.getcwd()) parser.add_argument("-v", "--verbose", action="count", default=1) diff --git a/project_manager/pipe/core.py b/project_manager/pipe/core.py index 9713619..94a87cd 100644 --- a/project_manager/pipe/core.py +++ b/project_manager/pipe/core.py @@ -73,10 +73,11 @@ class PipeRunner: pipe = self._parser.parse(pipeline) return self.run(pipe) - def check_conditions(self, conditions: List[str]) -> bool: - for condition in conditions: - if not self.resolve_value(condition): - return False + def check_conditions(self, conditions: Optional[List[str]]) -> bool: + if conditions: + for condition in conditions: + if not self.resolve_value(condition): + return False return True diff --git a/project_manager/pipe/type/file.py b/project_manager/pipe/type/file.py index c0ba867..00b1cfc 100644 --- a/project_manager/pipe/type/file.py +++ b/project_manager/pipe/type/file.py @@ -1,37 +1,56 @@ from pathlib import Path from typing import Any, Optional +from ...util import reflect from ..core import Pipe -from ..util import reflect -__all__ = ("FilePipe",) +__all__ = ( + "FileData", + "FilePipe", +) -class FilePipe(Pipe[None, Any]): - type = "file" +class FileData: _LOADERS = { ".toml": "tomli:load", ".json": "json:load", } - def __init__(self, filename: Optional[str], loader: Optional[str] = None): - self._filename = filename + def __init__(self, filepath: Path, loader: Optional[str] = None): + self._filepath = filepath self._loader = loader + self._data = None def _resolve_loader(self, filepath: Path): loader = self._loader or self._LOADERS.get(filepath.suffix) return reflect(loader) if loader else None + def _load_data(self): + loader = self._resolve_loader(self._filepath) + with open(self._filepath, "rb") as fd: + if loader: + self._data = loader(fd) + else: + self._data = fd.read() + + def get(self, key: str): + if self._data is None and self._filepath.exists(): + self._load_data() + return self._data.get(key) + + def __bool__(self): + return self._filepath.exists() + + +class FilePipe(Pipe[None, Any]): + type = "file" + + def __init__(self, filename: Optional[str], loader: Optional[str] = None): + self._filename = filename + self._loader = loader + def apply(self, value: Optional[str], basepath: Path) -> Optional[Any]: filepath = Path(value or self._filename) # TODO: raise none filepath exception if not filepath.is_absolute(): filepath = basepath / filepath - if not filepath.exists(): - return None - loader = self._resolve_loader(filepath) - with open(filepath, "rb") as fd: - if loader: - result = loader(fd) - else: - result = fd.read() - return result + return FileData(filepath, self._loader) diff --git a/project_manager/pipe/util.py b/project_manager/pipe/util.py deleted file mode 100644 index a4dd461..0000000 --- a/project_manager/pipe/util.py +++ /dev/null @@ -1,13 +0,0 @@ -import importlib -from typing import Any - -__all__ = ("reflect",) - - -def reflect(value: str) -> Any: - 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) diff --git a/project_manager/preset/default/nodejs.toml b/project_manager/preset/default/nodejs.toml new file mode 100644 index 0000000..3c0cd06 --- /dev/null +++ b/project_manager/preset/default/nodejs.toml @@ -0,0 +1,18 @@ +[presets.nodejs] +conditions = ["file(package.json)"] + +[presets.nodejs.tasks.setup.npm] +conditions = ["file(package-lock.json)"] +commands = ["npm install-clean --legacy-peer-deps"] + +[presets.nodejs.tasks.setup.npm-legacy] +conditions = ["file(package-lock.json) | not"] +commands = ["npm install --legacy-peer-deps"] + +[presets.nodejs.tasks.test.jest] +conditions = ["file(node_modules/jest)"] +commands = ["npx jest"] + +[presets.nodejs.tasks.clean.node_modules] +conditions = ["file(node_modules)"] +commands = ["rm -rf node_modules"] diff --git a/project_manager/preset/default/python.toml b/project_manager/preset/default/python.toml new file mode 100644 index 0000000..d14906d --- /dev/null +++ b/project_manager/preset/default/python.toml @@ -0,0 +1,33 @@ +[presets.python] +conditions = ["file(pyproject.toml)"] + +[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(tool.poetry.dependencies.python) | search((\\d.\\d))" +commands = ["poetry env use {python_version}", "poetry install"] + +[presets.python.tasks.test.pytest] +conditions = ["file(.venv/bin/pytest)"] +commands = [".venv/bin/pytest -p no:warnings"] + +[presets.python.tasks.lint.isort] +conditions = ["file(.venv/bin/isort)"] +commands = [".venv/bin/isort --profile black --check-only ./"] + +[presets.python.tasks.format.isort] +conditions = ["file(.venv/bin/isort)"] +commands = [".venv/bin/isort ./"] + +[presets.python.tasks.lint.black] +conditions = ["file(.venv/bin/black)"] +commands = [".venv/bin/black --check --diff ./"] + +[presets.python.tasks.format.black] +conditions = ["file(.venv/bin/black)"] +commands = [".venv/bin/black ./"] + +[presets.python.tasks.clean.venv] +conditions = ["file(.venv)"] +commands = ["rm -rf .venv"] diff --git a/project_manager/preset/entry.py b/project_manager/preset/entry.py index d5f52cd..ff58275 100644 --- a/project_manager/preset/entry.py +++ b/project_manager/preset/entry.py @@ -5,7 +5,7 @@ from typing import Optional from ..entry import Entry, EntryDetector from ..pipe.core import PipeParser, PipeRunner -from .util import PresetUtil +from .resolve import PresetResolver __all__ = ("PresetEntryDetector",) @@ -14,28 +14,28 @@ class PresetEntryDetector(EntryDetector): _PIPE_PARSER = PipeParser() def __init__(self, filename: Path): - self._preset = PresetUtil.resolve_preset(filename) self._pipeparser = PipeParser() def detect_entry(self, basepath: Path) -> Optional["Entry"]: + preset = PresetResolver(basepath).resolve_preset() piperunner = PipeRunner(basepath) tasks = defaultdict(list) - for preset_name, preset in self._preset.presets.items(): - filename = basepath / preset.filename - if filename.exists(): - for task_name, task_pack in preset.tasks.items(): - for task_item_name, task in task_pack.items(): - if piperunner.check_conditions(task.conditions): - variables = ( - { - k: piperunner.resolve_value(v) - for k, v in task.variables.items() - } - if task.variables - else {} - ) - tasks[task_name] += [ - command.format(**variables) for command in task.commands - ] + for preset_name, preset in preset.presets.items(): + if not piperunner.check_conditions(preset.conditions): + continue + for task_name, task_pack in preset.tasks.items(): + for task_item_name, task in task_pack.items(): + if piperunner.check_conditions(task.conditions): + variables = ( + { + k: piperunner.resolve_value(v) + for k, v in task.variables.items() + } + if task.variables + else {} + ) + tasks[task_name] += [ + command.format(**variables) for command in task.commands + ] if tasks: return Entry(tasks, basepath) diff --git a/project_manager/preset/model.py b/project_manager/preset/model.py index e7a327d..9f3f56d 100644 --- a/project_manager/preset/model.py +++ b/project_manager/preset/model.py @@ -2,19 +2,19 @@ from typing import Dict, List, Optional from pydantic import BaseModel -__all__ = ("TaskPreset", "FilePreset", "Preset") +__all__ = ("TaskPreset", "EntryPreset", "Preset") class TaskPreset(BaseModel): - conditions: List[str] + conditions: Optional[List[str]] variables: Optional[Dict[str, str]] commands: List[str] -class FilePreset(BaseModel): - filename: str +class EntryPreset(BaseModel): + conditions: Optional[List[str]] tasks: Dict[str, Dict[str, TaskPreset]] class Preset(BaseModel): - presets: Dict[str, FilePreset] + presets: Dict[str, EntryPreset] diff --git a/project_manager/preset/resolve.py b/project_manager/preset/resolve.py new file mode 100644 index 0000000..9018742 --- /dev/null +++ b/project_manager/preset/resolve.py @@ -0,0 +1,41 @@ +__all__ = ("PresetResolver",) + + +from pathlib import Path +from typing import Dict, Iterable + +import tomli + +from ..util import merge_dicts +from .model import Preset + + +class PresetResolver: + _source_cache: Dict[Path, dict] = {} + + def __init__(self, basepath: Path): + self._basepath = basepath + + def iter_sources(self) -> Iterable[Path]: + for filepath in (Path(__file__).parent / "default").iterdir(): + yield filepath + user_preset = Path.home() / ".preset.toml" + if user_preset.exists(): + yield user_preset + projectpath = self._basepath + while projectpath.exists(): + project_preset = projectpath / ".preset.toml" + if project_preset.exists(): + yield project_preset + if projectpath.parent == projectpath: + break + projectpath = projectpath.parent + + def resolve_preset(self) -> Preset: + result_preset_data = {} + 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) + merge_dicts(result_preset_data, self._source_cache[filepath]) + return Preset(**result_preset_data) diff --git a/project_manager/preset/util.py b/project_manager/preset/util.py deleted file mode 100644 index 63b5ee0..0000000 --- a/project_manager/preset/util.py +++ /dev/null @@ -1,15 +0,0 @@ -from pathlib import Path - -import tomli - -from .model import Preset - -__all__ = ("PresetUtil",) - - -class PresetUtil: - @classmethod - def resolve_preset(cls, filename: Path) -> Preset: - with open(filename, "rb") as fd: - preset_data = tomli.load(fd) - return Preset(**preset_data) diff --git a/project_manager/util.py b/project_manager/util.py new file mode 100644 index 0000000..363056d --- /dev/null +++ b/project_manager/util.py @@ -0,0 +1,28 @@ +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: + target[key] = val + continue + + if isinstance(val, dict): + merge_dicts(target[key], val) + else: + target[key] = val + return target