diff --git a/README.md b/README.md index 2dacd7f..43e1add 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ## Install ```bash -pip install project-manager -i https://instreamatic.com/nexus/repository/pypi/simple/ +pipx install project-manager -i https://git.shmyga.ru/InfernalGames/project-manager/packages ``` ## Usage @@ -17,42 +17,25 @@ pm [-v] [-vv] ### Run task ```bash -pm [-v] [-vv] +pm [:] [-v] [-vv] ``` ## Presets locations -- default presets `$PROJECT_MANAGER/project_manager/preset/default/*.toml` +- default presets [`project_manager/preset/default/*.toml`](./project_manager/preset/default) - user preset `$HOME/.pm.toml` - project preset `./.pm.toml` - ## Preset format ### Template ```toml -[presets..tasks..] +[presets..tasks..] conditions = [""] commands = [""] ``` -### Copy default config sample +### Examples -```toml -[presets.config.tasks.setup.config] -conditions = ["file(config/base.yaml)", "file(config/current.yaml) | not"] -commands = ["cp ../../config/admin/dev/localhost.yaml config/current.yaml"] -``` - -## Condition pipe format - -`function_name1(arg1,arg2) | function_name2(arg1)` - -### Condition functions - -- **file**: `file(filename)` - check file/directory exists or load file content as dict for next pipe -- **get**: `get(keypath)` - get value from dict by key path with dot separator -- **not**: `not` - invert pipe value as bool -- **equal**: `equal(string)` - compare pipe value with string -- **search**: `search(regexp, [index=1])` - match pipe value with regexp and return matched group by index (default 1) +[Default presets](./project_manager/preset/default) diff --git a/poetry.toml b/poetry.toml index ab1033b..1186582 100644 --- a/poetry.toml +++ b/poetry.toml @@ -1,2 +1,6 @@ [virtualenvs] in-project = true + +[repositories] +[repositories.InfernalGames] +url = "https://git.shmyga.ru/api/packages/InfernalGames/pypi" diff --git a/project_manager/entry/detector.py b/project_manager/entry/detector.py index 234e1f3..ed58333 100644 --- a/project_manager/entry/detector.py +++ b/project_manager/entry/detector.py @@ -5,7 +5,7 @@ from .entry import Entry class EmptyEntry(Entry): def __init__(self): - super().__init__({}, Path()) + super().__init__([], Path(), Path()) def __bool__(self): return False @@ -16,5 +16,5 @@ ENTRY_SKIP = EmptyEntry() class EntryDetector: - def detect_entry(self, basepath: Path) -> Entry: + def detect_entry(self, basepath: Path, rootpath: Path) -> Entry: raise NotImplementedError diff --git a/project_manager/entry/entry.py b/project_manager/entry/entry.py index e57a83d..8b23f5e 100644 --- a/project_manager/entry/entry.py +++ b/project_manager/entry/entry.py @@ -1,6 +1,6 @@ import logging from pathlib import Path -from typing import NamedTuple +from typing import NamedTuple, Self from ..common.command import CommandExecutor @@ -10,9 +10,45 @@ logger = logging.getLogger("entry") class TaskKey(NamedTuple): name: str tag: str + sub_tag: str def __repr__(self) -> str: - return f"{self.name}:{self.tag}" + return self.to_str() + + def to_str(self, parts: int | None = None) -> str: + values = list(self) + if parts is not None: + values = values[:parts] + return ":".join(filter(bool, values)) + + +class PartialTaskKey(TaskKey): + tag: str | None + sub_tag: str | None + + @classmethod + def from_str(cls, value: str) -> Self: + parts = value.split(":") + parts = parts[:3] + [None for _ in range(3 - len(parts))] + return cls(*parts) + + def __eq__(self, value: object) -> bool: + if isinstance(value, TaskKey): + if self.name != value.name: + return False + if self.tag and self.tag != value.tag: + return False + if self.sub_tag and self.sub_tag != value.sub_tag: + return False + return True + return super().__eq__(value) + + +class Task(NamedTuple): + key: TaskKey + commands: list[str] + priority: int + cwd: str | None = None class Entry: @@ -20,31 +56,50 @@ class Entry: def __init__( self, - tasks: dict[TaskKey, list[str]], + tasks: list[Task], basepath: Path, + rootpath: Path, ): self._tasks = tasks self._basepath = basepath + self._roopath = rootpath def show(self): self._logger.info("🔵 %s", self) - for task_name, commands in self._tasks.items(): - self._logger.info(" %s", task_name) - for command in commands: - self._logger.debug(" %s", command) + level = logging.root.level + logged = set() + for task in self._tasks: + if level == logging.INFO: + if task.key.name not in logged: + self._logger.info(" %s", task.key.name) + logged.add(task.key.name) + elif level == logging.DEBUG: + task_title = task.key.to_str(2) + if task_title not in logged: + self._logger.info(" %s", task_title) + logged.add(task_title) + elif level == logging.TRACE: + self._logger.info(" %s", task.key) + if task.cwd: + self._logger.debug(" > %s", task.cwd) + for command in task.commands: + self._logger.debug(" %s", command) - def run_task(self, task_name: str, tag: str | None = None) -> bool: - if task_name == "show": + def run_task(self, key: PartialTaskKey) -> bool: + if key.name == "show": self.show() return True runned = False - for task_key, commands in self._tasks.items(): - if task_key.name == task_name and (tag is None or tag == task_key.tag): - self._logger.info("🔵 %s %s", task_key, self) + for task in self._tasks: + if key == task.key: + self._logger.info("🔵 %s %s", task.key, self) runned = True - for command in commands: - CommandExecutor(command).run(self._basepath) + for command in task.commands: + CommandExecutor(command).run( + (task.cwd and Path(task.cwd)) or self._basepath + ) return runned def __repr__(self): - return f"{str(self._basepath)}" + path = self._basepath.relative_to(self._roopath.parent) + return f"[{str(path)}]" diff --git a/project_manager/entry/resolver.py b/project_manager/entry/resolver.py index 55b0f6a..72444f0 100644 --- a/project_manager/entry/resolver.py +++ b/project_manager/entry/resolver.py @@ -14,11 +14,11 @@ class EntryResolver: self._ignore = ignore self._max_depth = max_depth - def _resolve(self, basepath: Path, depth: int = 0) -> Iterable[Entry]: + def _resolve(self, rootpath: Path, basepath: Path, depth: int = 0) -> Iterable[Entry]: if basepath.is_dir(): if basepath.name.startswith(".") or self._ignore.match_file(basepath): return - entry = self._detector.detect_entry(basepath) + entry = self._detector.detect_entry(basepath, rootpath) if entry: yield entry if ( @@ -27,7 +27,7 @@ class EntryResolver: and basepath.is_dir() ): for path in basepath.iterdir(): - yield from self._resolve(path, depth + 1) + yield from self._resolve(rootpath, path, depth + 1) def resolve(self, basepath: Path) -> Iterable[Entry]: - return self._resolve(basepath) + return self._resolve(basepath, basepath) diff --git a/project_manager/manager.py b/project_manager/manager.py index f60760f..cb49998 100644 --- a/project_manager/manager.py +++ b/project_manager/manager.py @@ -6,7 +6,7 @@ from typing import Iterable from .common.command import CommandError from .common.ignore import create_ignore -from .entry.entry import Entry +from .entry.entry import Entry, PartialTaskKey from .entry.resolver import EntryResolver from .preset.detector import PresetEntryDetector @@ -36,11 +36,11 @@ class ProjectManager: def _iter_entries(self) -> Iterable[Entry]: return self._resolver.resolve(self._basepaht) - def run_task(self, task_name: str, tag: str | None = None) -> dict[str, int]: + def run_task(self, key: PartialTaskKey) -> dict[str, int]: counter = Counter() for entry in self._iter_entries(): try: - if entry.run_task(task_name, tag): + if entry.run_task(key): counter[ResultType.SUCCESS] += 1 except CommandError as error: self._logger.error("%s\n%s", entry, error.output) diff --git a/project_manager/preset/detector.py b/project_manager/preset/detector.py index 78cb9b0..ad77673 100644 --- a/project_manager/preset/detector.py +++ b/project_manager/preset/detector.py @@ -1,9 +1,9 @@ -from collections import defaultdict +import operator from pathlib import Path from ..common.template import PathTemplate from ..entry.detector import ENTRY_NONE, ENTRY_SKIP, EntryDetector -from ..entry.entry import Entry, TaskKey +from ..entry.entry import Entry, Task, TaskKey from .loader import PresetLoader, PresetLoaderConfig from .model import Variables @@ -13,12 +13,12 @@ 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: + def detect_entry(self, basepath: Path, rootpath: Path) -> Entry: template = PathTemplate(basepath) preset = PresetLoader(basepath, self._preset_loader_config).load() if preset.skip: return ENTRY_SKIP - tasks = defaultdict(list) + tasks = [] for tag, preset in preset.presets.items(): preset_variables: Variables = {"entry_path": basepath} if preset.variables: @@ -28,17 +28,24 @@ class PresetEntryDetector(EntryDetector): ): continue for task_name, task_pack in preset.tasks.items(): - for task in task_pack.values(): - if not task.conditions or template.check_conditions( - task.conditions, preset_variables + for sub_tag, task_preset in task_pack.items(): + if not task_preset.conditions or template.check_conditions( + task_preset.conditions, preset_variables ): variables = preset_variables.copy() - if task.variables: - template.update_variables(task.variables, variables) - tasks[TaskKey(task_name, tag)] += [ - template.fill_template(command, variables) - for command in task.commands - ] + if task_preset.variables: + template.update_variables(task_preset.variables, variables) + task = Task( + key=TaskKey(task_name, tag, sub_tag), + commands=[ + template.fill_template(command, variables) + for command in task_preset.commands + ], + priority=task_preset.priority, + cwd=task_preset.cwd, + ) + tasks.append(task) if tasks: - return Entry(tasks, basepath) + tasks = sorted(tasks, key=operator.attrgetter("priority"), reverse=True) + return Entry(tasks, basepath, rootpath) return ENTRY_NONE diff --git a/project_manager/preset/model.py b/project_manager/preset/model.py index f140df6..ab84f8d 100644 --- a/project_manager/preset/model.py +++ b/project_manager/preset/model.py @@ -4,6 +4,8 @@ Variables = dict[str, str] class TaskPreset(BaseModel): + cwd: str | None = None + priority: int = 0 conditions: list[str] | None = None variables: Variables | None = None commands: list[str] diff --git a/project_manager/runner.py b/project_manager/runner.py index 85aa672..41f5641 100644 --- a/project_manager/runner.py +++ b/project_manager/runner.py @@ -5,14 +5,20 @@ from pathlib import Path from typing import Self from .common.logging_colored import ColoredFormatter +from .entry.entry import PartialTaskKey from .manager import ProjectManager class ProjectManagerRunner: - def __init__(self, *, path: Path, task: str, tag: str | None, verbose: int = 0): + def __init__( + self, + *, + path: Path, + task_key: PartialTaskKey, + verbose: int = 0, + ): self._path = path - self._task = task - self._tag = tag + self._task_key = task_key self._verbose = verbose @classmethod @@ -39,7 +45,7 @@ class ProjectManagerRunner: def run(self): self._setup_logging(self._verbose) manager = ProjectManager(self._path) - result = manager.run_task(self._task, self._tag) + result = manager.run_task(self._task_key) if result["failed"] > 0: sys.exit(1) @@ -56,8 +62,5 @@ class ProjectManagerRunner: parser.add_argument("-q", "--quiet", action="store_true") args = parser.parse_args() verbose = -1 if args.quiet else args.verbose - task = args.task - tag = None - if ":" in task: - task, tag = task.split(":") - return cls(path=args.path, task=task, tag=tag, verbose=verbose) + task_key = PartialTaskKey.from_str(args.task) + return cls(path=args.path, task_key=task_key, verbose=verbose) diff --git a/tests/test_entry.py b/tests/test_entry.py index 6e5c0c7..6984d8f 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest from project_manager.entry.detector import EntryDetector -from project_manager.entry.entry import Entry +from project_manager.entry.entry import Entry, PartialTaskKey from project_manager.preset.detector import PresetEntryDetector from project_manager.preset.loader import PresetLoaderConfig @@ -26,26 +26,28 @@ def entry_detector(preset_loader_config: PresetLoaderConfig): @pytest.fixture def entry(entry_detector: EntryDetector, entry_path: Path, tmp_path: Path): destination = shutil.copytree(entry_path, tmp_path / entry_path.name) - return entry_detector.detect_entry(destination) + return entry_detector.detect_entry(destination, destination) @pytest.mark.parametrize("entry_path", [Path(__file__).parent / "python"]) def test_python_entry(entry: Entry | None, entry_detector: EntryDetector): assert entry is not None - assert {"setup", "build"} == {k.name for k in entry._tasks.keys()} - entry.run_task("setup") - entry = entry_detector.detect_entry(entry._basepath) + assert {"setup", "build"} == {task.key.name for task in entry._tasks} + entry.run_task(PartialTaskKey.from_str("setup")) + entry = entry_detector.detect_entry(entry._basepath, entry._basepath) assert entry is not None assert {"setup", "build", "lint", "format", "test", "clean"} == { - k.name for k in entry._tasks.keys() + task.key.name for task in entry._tasks } @pytest.mark.parametrize("entry_path", [Path(__file__).parent / "nodejs"]) def test_nodejs_entry(entry: Entry | None, entry_detector: EntryDetector): assert entry is not None - assert {"setup", "build"} == {k.name for k in entry._tasks.keys()} - entry.run_task("setup") - entry = entry_detector.detect_entry(entry._basepath) + assert {"setup", "build"} == {task.key.name for task in entry._tasks} + entry.run_task(PartialTaskKey.from_str("setup")) + entry = entry_detector.detect_entry(entry._basepath, entry._basepath) assert entry is not None - assert {"setup", "build", "test", "clean"} == {k.name for k in entry._tasks.keys()} + assert {"setup", "build", "test", "clean"} == { + task.key.name for task in entry._tasks + }