diff options
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | license | 19 | ||||
| -rw-r--r-- | patchtree/__init__.py | 4 | ||||
| -rw-r--r-- | patchtree/cli.py | 66 | ||||
| -rw-r--r-- | patchtree/config.py | 31 | ||||
| -rw-r--r-- | patchtree/context.py | 42 | ||||
| -rw-r--r-- | patchtree/diff.py | 50 | ||||
| -rw-r--r-- | patchtree/patch.py | 74 | ||||
| -rw-r--r-- | patchtree/process.py | 33 | ||||
| -rw-r--r-- | pyproject.toml | 25 | ||||
| -rw-r--r-- | readme.md | 3 | ||||
| -rw-r--r-- | setup.py | 2 |
12 files changed, 354 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c93d10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv +venv +*.egg-info +build +__pycache__ @@ -0,0 +1,19 @@ +Copyright (c) 2025 Loek Le Blansch <loek.le-blansch.pv@renesas.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/patchtree/__init__.py b/patchtree/__init__.py new file mode 100644 index 0000000..e7f3568 --- /dev/null +++ b/patchtree/__init__.py @@ -0,0 +1,4 @@ +from .config import Config +from .diff import Diff, IgnoreDiff +from .context import Context +from .process import ProcessCoccinelle, ProcessJinja2 diff --git a/patchtree/cli.py b/patchtree/cli.py new file mode 100644 index 0000000..104564d --- /dev/null +++ b/patchtree/cli.py @@ -0,0 +1,66 @@ +from dataclasses import fields +from sys import stderr +from pathlib import Path + +from .context import Context +from .config import Config + +def load_config() -> Config: + init = {} + cfg = Path("ptconfig.py") + if cfg.exists(): + exec(cfg.read_text(), init) + valid_fields = [field.name for field in fields(Config)] + init = {key: value for key, value in init.items() if key in valid_fields} + return Config(**init) + +def parse_arguments(config: Config) -> Context: + parser = config.argument_parser( + prog='patchtree', + description='patch file generator', + ) + parser.add_argument( + '-o', '--out', + help="output file (stdout by default)", + type=str, + ) + parser.add_argument('target', help="target directory or archive") + parser.add_argument("patch", help="patch input glob(s)", nargs="+") + + options = parser.parse_args() + + try: + return config.context(options) + except Exception as e: + parser.error(str(e)) + +def main(): + config = load_config() + print(config) + + context = parse_arguments(config) + + file_set: set[Path] = set() + for pattern in context.options.patch: + for path in Path('.').glob(pattern): + if not path.is_file(): + continue + file_set.add(path) + files = sorted(file_set) + + if len(files) == 0: + print("no files to patch!", file=stderr) + return 0 + + for file in files: + patch = config.patch(config, file) + patch.write_diff(context) + + context.output.flush() + context.output.close() + + return 0 + +if __name__ == "__main__": + exit(main()) + diff --git a/patchtree/config.py b/patchtree/config.py new file mode 100644 index 0000000..11f92f9 --- /dev/null +++ b/patchtree/config.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field +from argparse import ArgumentParser + +from .context import Context +from .patch import Patch +from .process import * +from .diff import * + +DEFAULT_PROCESSORS: dict[str, type[Process]] = { + "jinja": ProcessJinja2, + "cocci": ProcessCoccinelle, + "smpl": ProcessCoccinelle, +} + +DEFAULT_DIFFS: dict[str, type[Diff]] = { + ".gitignore": IgnoreDiff, +} + +@dataclass +class Config: + context: type[Context] = Context + patch: type[Patch] = Patch + argument_parser: type[ArgumentParser] = ArgumentParser + process_delimiter: str = "#" + processors: dict[str, type[Process]] = field(default_factory=lambda: DEFAULT_PROCESSORS) + diff_strategies: dict[str, type[Diff]] = field(default_factory=lambda: DEFAULT_DIFFS) + + def __post_init__(self): + self.processors = {**DEFAULT_PROCESSORS, **self.processors} + self.diff_strategies = {**DEFAULT_DIFFS, **self.diff_strategies} + diff --git a/patchtree/context.py b/patchtree/context.py new file mode 100644 index 0000000..914b3f1 --- /dev/null +++ b/patchtree/context.py @@ -0,0 +1,42 @@ +from argparse import Namespace +from typing import Union, IO +from pathlib import Path as DiskPath +from zipfile import is_zipfile, Path as ZipPath +from os import path +from sys import stdout + +class Context: + """ + Generic "context" struct that Diff and the main() function depend on for + application context. + """ + + fs: Union[DiskPath, ZipPath] + output: IO = stdout + options: Namespace + + def __init__(self, options: Namespace): + self.options = options + + target = options.target + if not path.exists(target): + raise Exception(f"cannot open `{target}'") + + if path.isdir(target): + self.fs = DiskPath(target) + elif is_zipfile(target): + self.fs = ZipPath(target) + else: + raise Exception("cannot read `{target}'") + + def get_dir(self, dir: str) -> list[str]: + here = self.fs.joinpath(dir) + print(here) + return [path.name for path in here.iterdir()] + + def get_content(self, file: str) -> str | None: + here = self.fs.joinpath(file) + if not here.exists(): + return None + return here.read_bytes().decode() + diff --git a/patchtree/diff.py b/patchtree/diff.py new file mode 100644 index 0000000..5086e95 --- /dev/null +++ b/patchtree/diff.py @@ -0,0 +1,50 @@ +from difflib import unified_diff + +class Diff: + """ + The base Diff class just produces a regular diff from the (possibly absent) + SDK10 file. This effectively adds a new file or replaces the SDK10 source file + with the file in the patch directory. + """ + + file: str + + content_a: str | None + content_b: str = "" + + def __init__(self, file: str): + self.file = file + + def compare(self) -> str: + a = [] if self.content_a is None else self.content_a.splitlines() + fromfile = "/dev/null" if self.content_a is None else f"a/{self.file}" + + b = self.content_b.strip().splitlines() + b = [line.rstrip() for line in b] + tofile = f"b/{self.file}" + + diff = unified_diff(a, b, fromfile, tofile, n=0, lineterm="") + return "\n".join(diff) + "\n" + + def diff(self) -> str: + return self.compare() + +class IgnoreDiff(Diff): + """ + IgnoreDiff is slightly different and is used to ensure all the lines in the + patch source ignore file are present in the SDK version. This ensures no + duplicate ignore lines exist after patching. + """ + + def diff(self): + if self.content_a is None: + self.content_a = "" + lines_a = self.content_a.splitlines() + lines_b = self.content_b.splitlines() + + add_lines = set(lines_b) - set(lines_a) + + self.content_b = "\n".join((*lines_a, *add_lines,)) + + return self.compare() + diff --git a/patchtree/patch.py b/patchtree/patch.py new file mode 100644 index 0000000..8e07f59 --- /dev/null +++ b/patchtree/patch.py @@ -0,0 +1,74 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from pathlib import Path + +from .diff import Diff + +if TYPE_CHECKING: + from .process import Process + from .context import Context + from .config import Config + +class Patch: + config: Config + patch: Path + + file: str + file_name: str = "" + file_type: str = "" + processors: list[str] = [] + + def __init__(self, config: Config, patch: Path): + self.patch = patch + self.config = config + + self.file_name = patch.name + + # find preprocessors + idx = self.file_name.find(config.process_delimiter) + if idx >= 0: + self.processors = self.file_name[idx:].split(config.process_delimiter) + self.processors = [template.strip() for template in self.processors] + self.processors = [template for template in self.processors if len(template) > 0] + self.file_name = self.file_name[:idx] + + # save the path to the target file + self.file = str(patch.parent.joinpath(self.file_name)) + + # find and split at file extension + idx = self.file_name.find(".") + if idx >= 0: + self.file_type = self.file_name[idx:] + self.file_name = self.file_name[:idx] + + def get_diff(self) -> type[Diff]: + return self.config.diff_strategies.get(self.file_type, Diff) + + def get_processors(self) -> list[type[Process]]: + processors = [] + for processor in self.processors: + if processor not in self.config.processors: + continue + processors.append(self.config.processors[processor]) + return processors + + def write_diff(self, context: Context) -> None: + diff_class = self.get_diff() + processor_classes = self.get_processors() + + diff = diff_class(self.file) + + # read file A contents + diff.content_a = context.get_content(self.file) + + # read file B contents + content_b = self.patch.read_text() + for processor_class in processor_classes: + processor = processor_class(context) + content_b = processor.transform(content_b) + diff.content_b = content_b + + delta = diff.diff() + context.output.write(delta) + diff --git a/patchtree/process.py b/patchtree/process.py new file mode 100644 index 0000000..2846d28 --- /dev/null +++ b/patchtree/process.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, Any + +from jinja2 import Environment + +if TYPE_CHECKING: + from .context import Context + +class Process: + context: Context + + def __init__(self, context: Context): + self.context = context + + def transform(self, input: str) -> str: + return input + +class ProcessJinja2(Process): + environment: Environment = Environment( + trim_blocks=True, + lstrip_blocks=True, + ) + + def transform(self, input: str) -> str: + template_vars = self.get_template_vars() + return self.environment.from_string(input).render(**template_vars) + + def get_template_vars(self) -> dict[str, Any]: + return {} + +class ProcessCoccinelle(Process): + pass + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..179169b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[project] +name = "patchtree" +description = "generate clean patches for external source trees" +version = "0.1.0" +authors = [ + { name="Loek Le Blansch", email="loek.le-blansch.pv@renesas.com" }, +] +readme = "readme.md" +requires-python = ">=3.9" +license = "MIT" +license-files = ["license"] +dependencies = [ + "jinja2", +] + +[project.urls] +Homepage = "https://bitbucket.global.renesas.com/users/loek.le-blansch.pv_renesas.com/repos/patchtree" + +[project.scripts] +patchtree = "patchtree.cli:main" + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..08a114c --- /dev/null +++ b/readme.md @@ -0,0 +1,3 @@ +# patchtree + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8bf1ba9 --- /dev/null +++ b/setup.py @@ -0,0 +1,2 @@ +from setuptools import setup +setup() |