feat: add task key sub_tag

This commit is contained in:
2026-08-28 15:44:46 +03:00
parent a77a430d51
commit 5401f04485
10 changed files with 136 additions and 80 deletions
+6 -23
View File
@@ -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 <task> [-v] [-vv]
pm <task>[:<tag>] [-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.<entry_type>.tasks.<task_name>.<tag>]
[presets.<tag>.tasks.<task>.<type>]
conditions = ["<condition>"]
commands = ["<command>"]
```
### 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)
+4
View File
@@ -1,2 +1,6 @@
[virtualenvs]
in-project = true
[repositories]
[repositories.InfernalGames]
url = "https://git.shmyga.ru/api/packages/InfernalGames/pypi"
+2 -2
View File
@@ -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
+70 -15
View File
@@ -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)}]"
+4 -4
View File
@@ -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)
+3 -3
View File
@@ -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)
+21 -14
View File
@@ -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
+2
View File
@@ -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]
+12 -9
View File
@@ -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)
+12 -10
View File
@@ -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
}