feat: use .gitignore
This commit is contained in:
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,7 +27,7 @@ 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:
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user