feat: add task key sub_tag
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```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
|
## Usage
|
||||||
@@ -17,42 +17,25 @@ pm [-v] [-vv]
|
|||||||
### Run task
|
### Run task
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pm <task> [-v] [-vv]
|
pm <task>[:<tag>] [-v] [-vv]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Presets locations
|
## 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`
|
- user preset `$HOME/.pm.toml`
|
||||||
- project preset `./.pm.toml`
|
- project preset `./.pm.toml`
|
||||||
|
|
||||||
|
|
||||||
## Preset format
|
## Preset format
|
||||||
|
|
||||||
### Template
|
### Template
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[presets.<entry_type>.tasks.<task_name>.<tag>]
|
[presets.<tag>.tasks.<task>.<type>]
|
||||||
conditions = ["<condition>"]
|
conditions = ["<condition>"]
|
||||||
commands = ["<command>"]
|
commands = ["<command>"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Copy default config sample
|
### Examples
|
||||||
|
|
||||||
```toml
|
[Default presets](./project_manager/preset/default)
|
||||||
[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)
|
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
[virtualenvs]
|
[virtualenvs]
|
||||||
in-project = true
|
in-project = true
|
||||||
|
|
||||||
|
[repositories]
|
||||||
|
[repositories.InfernalGames]
|
||||||
|
url = "https://git.shmyga.ru/api/packages/InfernalGames/pypi"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from .entry import Entry
|
|||||||
|
|
||||||
class EmptyEntry(Entry):
|
class EmptyEntry(Entry):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__({}, Path())
|
super().__init__([], Path(), Path())
|
||||||
|
|
||||||
def __bool__(self):
|
def __bool__(self):
|
||||||
return False
|
return False
|
||||||
@@ -16,5 +16,5 @@ ENTRY_SKIP = EmptyEntry()
|
|||||||
|
|
||||||
|
|
||||||
class EntryDetector:
|
class EntryDetector:
|
||||||
def detect_entry(self, basepath: Path) -> Entry:
|
def detect_entry(self, basepath: Path, rootpath: Path) -> Entry:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple, Self
|
||||||
|
|
||||||
from ..common.command import CommandExecutor
|
from ..common.command import CommandExecutor
|
||||||
|
|
||||||
@@ -10,9 +10,45 @@ logger = logging.getLogger("entry")
|
|||||||
class TaskKey(NamedTuple):
|
class TaskKey(NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
tag: str
|
tag: str
|
||||||
|
sub_tag: str
|
||||||
|
|
||||||
def __repr__(self) -> 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:
|
class Entry:
|
||||||
@@ -20,31 +56,50 @@ class Entry:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
tasks: dict[TaskKey, list[str]],
|
tasks: list[Task],
|
||||||
basepath: Path,
|
basepath: Path,
|
||||||
|
rootpath: Path,
|
||||||
):
|
):
|
||||||
self._tasks = tasks
|
self._tasks = tasks
|
||||||
self._basepath = basepath
|
self._basepath = basepath
|
||||||
|
self._roopath = rootpath
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
self._logger.info("🔵 %s", self)
|
self._logger.info("🔵 %s", self)
|
||||||
for task_name, commands in self._tasks.items():
|
level = logging.root.level
|
||||||
self._logger.info(" %s", task_name)
|
logged = set()
|
||||||
for command in commands:
|
for task in self._tasks:
|
||||||
self._logger.debug(" %s", command)
|
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:
|
def run_task(self, key: PartialTaskKey) -> bool:
|
||||||
if task_name == "show":
|
if key.name == "show":
|
||||||
self.show()
|
self.show()
|
||||||
return True
|
return True
|
||||||
runned = False
|
runned = False
|
||||||
for task_key, commands in self._tasks.items():
|
for task in self._tasks:
|
||||||
if task_key.name == task_name and (tag is None or tag == task_key.tag):
|
if key == task.key:
|
||||||
self._logger.info("🔵 %s %s", task_key, self)
|
self._logger.info("🔵 %s %s", task.key, self)
|
||||||
runned = True
|
runned = True
|
||||||
for command in commands:
|
for command in task.commands:
|
||||||
CommandExecutor(command).run(self._basepath)
|
CommandExecutor(command).run(
|
||||||
|
(task.cwd and Path(task.cwd)) or self._basepath
|
||||||
|
)
|
||||||
return runned
|
return runned
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"{str(self._basepath)}"
|
path = self._basepath.relative_to(self._roopath.parent)
|
||||||
|
return f"[{str(path)}]"
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ class EntryResolver:
|
|||||||
self._ignore = ignore
|
self._ignore = ignore
|
||||||
self._max_depth = max_depth
|
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.is_dir():
|
||||||
if basepath.name.startswith(".") or self._ignore.match_file(basepath):
|
if basepath.name.startswith(".") or self._ignore.match_file(basepath):
|
||||||
return
|
return
|
||||||
entry = self._detector.detect_entry(basepath)
|
entry = self._detector.detect_entry(basepath, rootpath)
|
||||||
if entry:
|
if entry:
|
||||||
yield entry
|
yield entry
|
||||||
if (
|
if (
|
||||||
@@ -27,7 +27,7 @@ class EntryResolver:
|
|||||||
and basepath.is_dir()
|
and basepath.is_dir()
|
||||||
):
|
):
|
||||||
for path in basepath.iterdir():
|
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]:
|
def resolve(self, basepath: Path) -> Iterable[Entry]:
|
||||||
return self._resolve(basepath)
|
return self._resolve(basepath, basepath)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Iterable
|
|||||||
|
|
||||||
from .common.command import CommandError
|
from .common.command import CommandError
|
||||||
from .common.ignore import create_ignore
|
from .common.ignore import create_ignore
|
||||||
from .entry.entry import Entry
|
from .entry.entry import Entry, PartialTaskKey
|
||||||
from .entry.resolver import EntryResolver
|
from .entry.resolver import EntryResolver
|
||||||
from .preset.detector import PresetEntryDetector
|
from .preset.detector import PresetEntryDetector
|
||||||
|
|
||||||
@@ -36,11 +36,11 @@ class ProjectManager:
|
|||||||
def _iter_entries(self) -> Iterable[Entry]:
|
def _iter_entries(self) -> Iterable[Entry]:
|
||||||
return self._resolver.resolve(self._basepaht)
|
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()
|
counter = Counter()
|
||||||
for entry in self._iter_entries():
|
for entry in self._iter_entries():
|
||||||
try:
|
try:
|
||||||
if entry.run_task(task_name, tag):
|
if entry.run_task(key):
|
||||||
counter[ResultType.SUCCESS] += 1
|
counter[ResultType.SUCCESS] += 1
|
||||||
except CommandError as error:
|
except CommandError as error:
|
||||||
self._logger.error("%s\n%s", entry, error.output)
|
self._logger.error("%s\n%s", entry, error.output)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from collections import defaultdict
|
import operator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..common.template import PathTemplate
|
from ..common.template import PathTemplate
|
||||||
from ..entry.detector import ENTRY_NONE, ENTRY_SKIP, EntryDetector
|
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 .loader import PresetLoader, PresetLoaderConfig
|
||||||
from .model import Variables
|
from .model import Variables
|
||||||
|
|
||||||
@@ -13,12 +13,12 @@ class PresetEntryDetector(EntryDetector):
|
|||||||
def __init__(self, preset_loader_config: PresetLoaderConfig | None = None):
|
def __init__(self, preset_loader_config: PresetLoaderConfig | None = None):
|
||||||
self._preset_loader_config = preset_loader_config
|
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)
|
template = PathTemplate(basepath)
|
||||||
preset = PresetLoader(basepath, self._preset_loader_config).load()
|
preset = PresetLoader(basepath, self._preset_loader_config).load()
|
||||||
if preset.skip:
|
if preset.skip:
|
||||||
return ENTRY_SKIP
|
return ENTRY_SKIP
|
||||||
tasks = defaultdict(list)
|
tasks = []
|
||||||
for tag, preset in preset.presets.items():
|
for tag, preset in preset.presets.items():
|
||||||
preset_variables: Variables = {"entry_path": basepath}
|
preset_variables: Variables = {"entry_path": basepath}
|
||||||
if preset.variables:
|
if preset.variables:
|
||||||
@@ -28,17 +28,24 @@ class PresetEntryDetector(EntryDetector):
|
|||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
for task_name, task_pack in preset.tasks.items():
|
for task_name, task_pack in preset.tasks.items():
|
||||||
for task in task_pack.values():
|
for sub_tag, task_preset in task_pack.items():
|
||||||
if not task.conditions or template.check_conditions(
|
if not task_preset.conditions or template.check_conditions(
|
||||||
task.conditions, preset_variables
|
task_preset.conditions, preset_variables
|
||||||
):
|
):
|
||||||
variables = preset_variables.copy()
|
variables = preset_variables.copy()
|
||||||
if task.variables:
|
if task_preset.variables:
|
||||||
template.update_variables(task.variables, variables)
|
template.update_variables(task_preset.variables, variables)
|
||||||
tasks[TaskKey(task_name, tag)] += [
|
task = Task(
|
||||||
template.fill_template(command, variables)
|
key=TaskKey(task_name, tag, sub_tag),
|
||||||
for command in task.commands
|
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:
|
if tasks:
|
||||||
return Entry(tasks, basepath)
|
tasks = sorted(tasks, key=operator.attrgetter("priority"), reverse=True)
|
||||||
|
return Entry(tasks, basepath, rootpath)
|
||||||
return ENTRY_NONE
|
return ENTRY_NONE
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Variables = dict[str, str]
|
|||||||
|
|
||||||
|
|
||||||
class TaskPreset(BaseModel):
|
class TaskPreset(BaseModel):
|
||||||
|
cwd: str | None = None
|
||||||
|
priority: int = 0
|
||||||
conditions: list[str] | None = None
|
conditions: list[str] | None = None
|
||||||
variables: Variables | None = None
|
variables: Variables | None = None
|
||||||
commands: list[str]
|
commands: list[str]
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ from pathlib import Path
|
|||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
from .common.logging_colored import ColoredFormatter
|
from .common.logging_colored import ColoredFormatter
|
||||||
|
from .entry.entry import PartialTaskKey
|
||||||
from .manager import ProjectManager
|
from .manager import ProjectManager
|
||||||
|
|
||||||
|
|
||||||
class ProjectManagerRunner:
|
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._path = path
|
||||||
self._task = task
|
self._task_key = task_key
|
||||||
self._tag = tag
|
|
||||||
self._verbose = verbose
|
self._verbose = verbose
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -39,7 +45,7 @@ class ProjectManagerRunner:
|
|||||||
def run(self):
|
def run(self):
|
||||||
self._setup_logging(self._verbose)
|
self._setup_logging(self._verbose)
|
||||||
manager = ProjectManager(self._path)
|
manager = ProjectManager(self._path)
|
||||||
result = manager.run_task(self._task, self._tag)
|
result = manager.run_task(self._task_key)
|
||||||
if result["failed"] > 0:
|
if result["failed"] > 0:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -56,8 +62,5 @@ class ProjectManagerRunner:
|
|||||||
parser.add_argument("-q", "--quiet", action="store_true")
|
parser.add_argument("-q", "--quiet", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
verbose = -1 if args.quiet else args.verbose
|
verbose = -1 if args.quiet else args.verbose
|
||||||
task = args.task
|
task_key = PartialTaskKey.from_str(args.task)
|
||||||
tag = None
|
return cls(path=args.path, task_key=task_key, verbose=verbose)
|
||||||
if ":" in task:
|
|
||||||
task, tag = task.split(":")
|
|
||||||
return cls(path=args.path, task=task, tag=tag, verbose=verbose)
|
|
||||||
|
|||||||
+12
-10
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from project_manager.entry.detector import EntryDetector
|
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.detector import PresetEntryDetector
|
||||||
from project_manager.preset.loader import PresetLoaderConfig
|
from project_manager.preset.loader import PresetLoaderConfig
|
||||||
|
|
||||||
@@ -26,26 +26,28 @@ def entry_detector(preset_loader_config: PresetLoaderConfig):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def entry(entry_detector: EntryDetector, entry_path: Path, tmp_path: Path):
|
def entry(entry_detector: EntryDetector, entry_path: Path, tmp_path: Path):
|
||||||
destination = shutil.copytree(entry_path, tmp_path / entry_path.name)
|
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"])
|
@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"} == {k.name for k in entry._tasks.keys()}
|
assert {"setup", "build"} == {task.key.name for task in entry._tasks}
|
||||||
entry.run_task("setup")
|
entry.run_task(PartialTaskKey.from_str("setup"))
|
||||||
entry = entry_detector.detect_entry(entry._basepath)
|
entry = entry_detector.detect_entry(entry._basepath, entry._basepath)
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert {"setup", "build", "lint", "format", "test", "clean"} == {
|
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"])
|
@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"} == {k.name for k in entry._tasks.keys()}
|
assert {"setup", "build"} == {task.key.name for task in entry._tasks}
|
||||||
entry.run_task("setup")
|
entry.run_task(PartialTaskKey.from_str("setup"))
|
||||||
entry = entry_detector.detect_entry(entry._basepath)
|
entry = entry_detector.detect_entry(entry._basepath, entry._basepath)
|
||||||
assert entry is not None
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user