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
+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():
if key not in target:
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
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)
+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
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
+9 -87
View File
@@ -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:
+1 -1
View File
@@ -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