From 9d15380443c765f6397ae0d80a7bb3399fb50dce Mon Sep 17 00:00:00 2001 From: shmyga Date: Thu, 6 Aug 2026 01:20:58 +0300 Subject: [PATCH] feat: use .gitignore --- poetry.lock | 4 +- project_manager/common/ignore.py | 19 ++++++ project_manager/common/template.py | 83 ++++++++++++++++++++++++ project_manager/{ => common}/util.py | 2 +- project_manager/entry/__init__.py | 0 project_manager/entry/detector.py | 8 +++ project_manager/{ => entry}/entry.py | 32 ++-------- project_manager/entry/resolver.py | 28 ++++++++ project_manager/manager.py | 35 ++++++---- project_manager/preset/detector.py | 96 +++------------------------- project_manager/preset/loader.py | 2 +- pyproject.toml | 1 + tests/test_entry.py | 3 +- 13 files changed, 183 insertions(+), 130 deletions(-) create mode 100644 project_manager/common/ignore.py create mode 100644 project_manager/common/template.py rename project_manager/{ => common}/util.py (82%) create mode 100644 project_manager/entry/__init__.py create mode 100644 project_manager/entry/detector.py rename project_manager/{ => entry}/entry.py (53%) create mode 100644 project_manager/entry/resolver.py diff --git a/poetry.lock b/poetry.lock index 40565ab..330198e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -319,7 +319,7 @@ version = "1.1.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, @@ -706,4 +706,4 @@ typing-extensions = ">=4.12.0" [metadata] lock-version = "2.1" python-versions = ">=3.14,<4.0" -content-hash = "3cb049a6273569896ccd4ba8b86b13d39bb4a45740ecf8205ecfd6faa2ff4e10" +content-hash = "2f3cf31120e4323f9a06b994eec418064b345d09c139c637cfed77c3770473d4" diff --git a/project_manager/common/ignore.py b/project_manager/common/ignore.py new file mode 100644 index 0000000..bb01ee2 --- /dev/null +++ b/project_manager/common/ignore.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from pathspec import PathSpec + +DEFAULT_IGNORE = [ + ".git", + "node_modules", + ".venv", + "__pycache__", +] + + +def create_ignore(basepath: Path) -> PathSpec: + lines = DEFAULT_IGNORE.copy() + gitignore = basepath / ".gitignore" + if gitignore.exists(): + with open(gitignore, "r") as fd: + lines += fd.readlines() + return PathSpec.from_lines("gitignore", lines) diff --git a/project_manager/common/template.py b/project_manager/common/template.py new file mode 100644 index 0000000..e440ab4 --- /dev/null +++ b/project_manager/common/template.py @@ -0,0 +1,83 @@ +import json +import re +import tomllib +from pathlib import Path + +import dotenv +from jinja2 import Environment + +from ..preset.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 PathTemplate: + 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 diff --git a/project_manager/util.py b/project_manager/common/util.py similarity index 82% rename from project_manager/util.py rename to project_manager/common/util.py index 3d787c0..6984842 100644 --- a/project_manager/util.py +++ b/project_manager/common/util.py @@ -1,4 +1,4 @@ -def merge_dicts(target, enhancer): +def merge_dicts(target: dict, enhancer: dict) -> dict: for key, val in enhancer.items(): if key not in target: target[key] = val diff --git a/project_manager/entry/__init__.py b/project_manager/entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project_manager/entry/detector.py b/project_manager/entry/detector.py new file mode 100644 index 0000000..c76c034 --- /dev/null +++ b/project_manager/entry/detector.py @@ -0,0 +1,8 @@ +from pathlib import Path + +from .entry import Entry + + +class EntryDetector: + def detect_entry(self, basepath: Path) -> Entry | None: + raise NotImplementedError diff --git a/project_manager/entry.py b/project_manager/entry/entry.py similarity index 53% rename from project_manager/entry.py rename to project_manager/entry/entry.py index 9e5a9ce..e57a83d 100644 --- a/project_manager/entry.py +++ b/project_manager/entry/entry.py @@ -1,8 +1,8 @@ import logging from pathlib import Path -from typing import Iterable, NamedTuple +from typing import NamedTuple -from .common.command import CommandExecutor +from ..common.command import CommandExecutor logger = logging.getLogger("entry") @@ -27,11 +27,11 @@ class Entry: self._basepath = basepath def show(self): - self._logger.info("%s", 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) + self._logger.debug(" %s", command) def run_task(self, task_name: str, tag: str | None = None) -> bool: if task_name == "show": @@ -40,7 +40,7 @@ class Entry: 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) + self._logger.info("🔵 %s %s", task_key, self) runned = True for command in commands: CommandExecutor(command).run(self._basepath) @@ -48,25 +48,3 @@ class Entry: def __repr__(self): return f"{str(self._basepath)}" - - -class EntryDetector: - def detect_entry(self, basepath: Path) -> Entry | None: - raise NotImplementedError - - -class EntryResolver: - def __init__(self, detector: EntryDetector, ignore: list[str]): - self._detector = detector - self._ignore = ignore - - def resolve(self, basepath: Path, depth: int = 128) -> Iterable[Entry]: - if basepath.is_dir(): - if basepath.name in self._ignore: - return - entry = self._detector.detect_entry(basepath) - if entry: - yield entry - if depth > 0 and basepath.is_dir(): - for path in basepath.iterdir(): - yield from self.resolve(path, depth - 1) diff --git a/project_manager/entry/resolver.py b/project_manager/entry/resolver.py new file mode 100644 index 0000000..a49ce48 --- /dev/null +++ b/project_manager/entry/resolver.py @@ -0,0 +1,28 @@ +from pathlib import Path +from typing import Iterable + +from pathspec import PathSpec + +from .detector import EntryDetector +from .entry import Entry + + +class EntryResolver: + def __init__(self, detector: EntryDetector, ignore: PathSpec, max_depth: int = 4): + self._detector = detector + self._ignore = ignore + self._max_depth = max_depth + + def _resolve(self, basepath: Path, depth: int = 0) -> Iterable[Entry]: + if basepath.is_dir(): + if self._ignore.match_file(basepath): + return + entry = self._detector.detect_entry(basepath) + if entry: + yield entry + if depth < self._max_depth and basepath.is_dir(): + for path in basepath.iterdir(): + yield from self._resolve(path, depth + 1) + + def resolve(self, basepath: Path) -> Iterable[Entry]: + return self._resolve(basepath) diff --git a/project_manager/manager.py b/project_manager/manager.py index d19b168..f60760f 100644 --- a/project_manager/manager.py +++ b/project_manager/manager.py @@ -1,26 +1,37 @@ import logging from collections import Counter +from enum import StrEnum from pathlib import Path from typing import Iterable from .common.command import CommandError -from .entry import Entry, EntryResolver +from .common.ignore import create_ignore +from .entry.entry import Entry +from .entry.resolver import EntryResolver from .preset.detector import PresetEntryDetector logger = logging.getLogger("project") +class ResultType(StrEnum): + SUCCESS = "success" + FAILED = "failed" + + +RESULT_ICONS = { + ResultType.SUCCESS: "🟢", + ResultType.FAILED: "🔴", +} + + class ProjectManager: - # TODO: from .gitignore? - DEFAULT_IGNORE = ["node_modules", ".venv", ".git", "tests", "__pycache__"] _logger = logger def __init__(self, basepath: Path): self._basepaht = basepath - self._resolver = EntryResolver( - PresetEntryDetector(), - self.DEFAULT_IGNORE, - ) + detector = PresetEntryDetector() + ignore = create_ignore(basepath) + self._resolver = EntryResolver(detector, ignore) def _iter_entries(self) -> Iterable[Entry]: return self._resolver.resolve(self._basepaht) @@ -30,12 +41,14 @@ class ProjectManager: for entry in self._iter_entries(): try: if entry.run_task(task_name, tag): - counter["success"] += 1 + counter[ResultType.SUCCESS] += 1 except CommandError as error: self._logger.error("%s\n%s", entry, error.output) - counter["failed"] += 1 + counter[ResultType.FAILED] += 1 except: self._logger.exception(entry) - counter["failed"] += 1 - self._logger.info("result %s", dict(counter)) + counter[ResultType.FAILED] += 1 + self._logger.info( + "%s", "\n".join(f"{RESULT_ICONS[k]} {v}" for k, v in counter.items()) + ) return counter diff --git a/project_manager/preset/detector.py b/project_manager/preset/detector.py index da3e2f9..5d77e64 100644 --- a/project_manager/preset/detector.py +++ b/project_manager/preset/detector.py @@ -1,98 +1,20 @@ -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 ..common.template import PathTemplate +from ..entry.detector import EntryDetector +from ..entry.entry import Entry, 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) + template = PathTemplate(basepath) preset = PresetLoader(basepath, self._preset_loader_config).load() if preset.skip: return None @@ -100,21 +22,21 @@ class PresetEntryDetector(EntryDetector): 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( + template.update_variables(preset.variables, preset_variables) + if preset.conditions and not template.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( + if not task.conditions or template.check_conditions( task.conditions, preset_variables ): variables = preset_variables.copy() if task.variables: - processor.update_variables(task.variables, variables) + template.update_variables(task.variables, variables) tasks[TaskKey(task_name, tag)] += [ - processor.fill_template(command, variables) + template.fill_template(command, variables) for command in task.commands ] if tasks: diff --git a/project_manager/preset/loader.py b/project_manager/preset/loader.py index 2d69a83..325099d 100644 --- a/project_manager/preset/loader.py +++ b/project_manager/preset/loader.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Iterable -from ..util import merge_dicts +from ..common.util import merge_dicts from .model import Preset diff --git a/pyproject.toml b/pyproject.toml index ad2ea27..049b3c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "pydantic (>=2.13.4,<3.0.0)", "dotenv (>=0.9.9,<0.10.0)", "jinja2 (>=3.1.6,<4.0.0)", + "pathspec (>=1.1.1,<2.0.0)", ] [dependency-groups] diff --git a/tests/test_entry.py b/tests/test_entry.py index d7f691f..6e5c0c7 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -4,7 +4,8 @@ from pathlib import Path import pytest -from project_manager.entry import Entry, EntryDetector +from project_manager.entry.detector import EntryDetector +from project_manager.entry.entry import Entry from project_manager.preset.detector import PresetEntryDetector from project_manager.preset.loader import PresetLoaderConfig