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."
|
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"
|
||||||
|
|||||||
@@ -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():
|
for key, val in enhancer.items():
|
||||||
if key not in target:
|
if key not in target:
|
||||||
target[key] = val
|
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
|
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,11 +27,11 @@ 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:
|
||||||
self._logger.debug(" %s", command)
|
self._logger.debug(" %s", command)
|
||||||
|
|
||||||
def run_task(self, task_name: str, tag: str | None = None) -> bool:
|
def run_task(self, task_name: str, tag: str | None = None) -> bool:
|
||||||
if task_name == "show":
|
if task_name == "show":
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user