feat: use .gitignore

This commit is contained in:
2026-08-06 01:20:58 +03:00
parent d062447e6d
commit 9d15380443
13 changed files with 183 additions and 130 deletions
Generated
+2 -2
View File
@@ -319,7 +319,7 @@ version = "1.1.1"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"] groups = ["main", "dev"]
files = [ files = [
{file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"},
{file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"},
@@ -706,4 +706,4 @@ typing-extensions = ">=4.12.0"
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.14,<4.0" python-versions = ">=3.14,<4.0"
content-hash = "3cb049a6273569896ccd4ba8b86b13d39bb4a45740ecf8205ecfd6faa2ff4e10" content-hash = "2f3cf31120e4323f9a06b994eec418064b345d09c139c637cfed77c3770473d4"
+19
View File
@@ -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)
+83
View File
@@ -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
@@ -1,4 +1,4 @@
def merge_dicts(target, enhancer): def merge_dicts(target: dict, enhancer: dict) -> dict:
for key, val in enhancer.items(): for key, val in enhancer.items():
if key not in target: if key not in target:
target[key] = val target[key] = val
View File
+8
View File
@@ -0,0 +1,8 @@
from pathlib import Path
from .entry import Entry
class EntryDetector:
def detect_entry(self, basepath: Path) -> Entry | None:
raise NotImplementedError
@@ -1,8 +1,8 @@
import logging import logging
from pathlib import Path 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") logger = logging.getLogger("entry")
@@ -27,7 +27,7 @@ class Entry:
self._basepath = basepath self._basepath = basepath
def show(self): def show(self):
self._logger.info("%s", self) self._logger.info("🔵 %s", self)
for task_name, commands in self._tasks.items(): for task_name, commands in self._tasks.items():
self._logger.info(" %s", task_name) self._logger.info(" %s", task_name)
for command in commands: for command in commands:
@@ -40,7 +40,7 @@ class Entry:
runned = False runned = False
for task_key, commands in self._tasks.items(): for task_key, commands in self._tasks.items():
if task_key.name == task_name and (tag is None or tag == task_key.tag): 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 runned = True
for command in commands: for command in commands:
CommandExecutor(command).run(self._basepath) CommandExecutor(command).run(self._basepath)
@@ -48,25 +48,3 @@ class Entry:
def __repr__(self): def __repr__(self):
return f"{str(self._basepath)}" 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)
+28
View File
@@ -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)
+24 -11
View File
@@ -1,26 +1,37 @@
import logging import logging
from collections import Counter from collections import Counter
from enum import StrEnum
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
from .common.command import CommandError 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 from .preset.detector import PresetEntryDetector
logger = logging.getLogger("project") logger = logging.getLogger("project")
class ResultType(StrEnum):
SUCCESS = "success"
FAILED = "failed"
RESULT_ICONS = {
ResultType.SUCCESS: "🟢",
ResultType.FAILED: "🔴",
}
class ProjectManager: class ProjectManager:
# TODO: from .gitignore?
DEFAULT_IGNORE = ["node_modules", ".venv", ".git", "tests", "__pycache__"]
_logger = logger _logger = logger
def __init__(self, basepath: Path): def __init__(self, basepath: Path):
self._basepaht = basepath self._basepaht = basepath
self._resolver = EntryResolver( detector = PresetEntryDetector()
PresetEntryDetector(), ignore = create_ignore(basepath)
self.DEFAULT_IGNORE, self._resolver = EntryResolver(detector, ignore)
)
def _iter_entries(self) -> Iterable[Entry]: def _iter_entries(self) -> Iterable[Entry]:
return self._resolver.resolve(self._basepaht) return self._resolver.resolve(self._basepaht)
@@ -30,12 +41,14 @@ class ProjectManager:
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(task_name, tag):
counter["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)
counter["failed"] += 1 counter[ResultType.FAILED] += 1
except: except:
self._logger.exception(entry) self._logger.exception(entry)
counter["failed"] += 1 counter[ResultType.FAILED] += 1
self._logger.info("result %s", dict(counter)) self._logger.info(
"%s", "\n".join(f"{RESULT_ICONS[k]} {v}" for k, v in counter.items())
)
return counter return counter
+9 -87
View File
@@ -1,98 +1,20 @@
import json
import re
import tomllib
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
import dotenv from ..common.template import PathTemplate
from jinja2 import Environment from ..entry.detector import EntryDetector
from ..entry.entry import Entry, TaskKey
from ..entry import Entry, EntryDetector, TaskKey
from .loader import PresetLoader, PresetLoaderConfig from .loader import PresetLoader, PresetLoaderConfig
from .model import Variables 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): 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 | None: def detect_entry(self, basepath: Path) -> Entry | None:
processor = PathProcessor(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 None return None
@@ -100,21 +22,21 @@ class PresetEntryDetector(EntryDetector):
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:
processor.update_variables(preset.variables, preset_variables) template.update_variables(preset.variables, preset_variables)
if preset.conditions and not processor.check_conditions( if preset.conditions and not template.check_conditions(
preset.conditions, preset_variables preset.conditions, preset_variables
): ):
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 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 task.conditions, preset_variables
): ):
variables = preset_variables.copy() variables = preset_variables.copy()
if task.variables: if task.variables:
processor.update_variables(task.variables, variables) template.update_variables(task.variables, variables)
tasks[TaskKey(task_name, tag)] += [ tasks[TaskKey(task_name, tag)] += [
processor.fill_template(command, variables) template.fill_template(command, variables)
for command in task.commands for command in task.commands
] ]
if tasks: if tasks:
+1 -1
View File
@@ -3,7 +3,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
from ..util import merge_dicts from ..common.util import merge_dicts
from .model import Preset from .model import Preset
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"pydantic (>=2.13.4,<3.0.0)", "pydantic (>=2.13.4,<3.0.0)",
"dotenv (>=0.9.9,<0.10.0)", "dotenv (>=0.9.9,<0.10.0)",
"jinja2 (>=3.1.6,<4.0.0)", "jinja2 (>=3.1.6,<4.0.0)",
"pathspec (>=1.1.1,<2.0.0)",
] ]
[dependency-groups] [dependency-groups]
+2 -1
View File
@@ -4,7 +4,8 @@ from pathlib import Path
import pytest 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.detector import PresetEntryDetector
from project_manager.preset.loader import PresetLoaderConfig from project_manager.preset.loader import PresetLoaderConfig