aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLoek Le Blansch <loek.le-blansch.pv@renesas.com>2025-10-24 08:58:05 +0200
committerLoek Le Blansch <loek.le-blansch.pv@renesas.com>2025-10-24 08:58:05 +0200
commit3915d09ac0c7741e61fd113f1dd35ca7db01669f (patch)
treed816a825739ced0d4131c15a7b1c1ff0f3af7afa
initial commit (split from sdk10-cmake)
-rw-r--r--.gitignore5
-rw-r--r--license19
-rw-r--r--patchtree/__init__.py4
-rw-r--r--patchtree/cli.py66
-rw-r--r--patchtree/config.py31
-rw-r--r--patchtree/context.py42
-rw-r--r--patchtree/diff.py50
-rw-r--r--patchtree/patch.py74
-rw-r--r--patchtree/process.py33
-rw-r--r--pyproject.toml25
-rw-r--r--readme.md3
-rw-r--r--setup.py2
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__
diff --git a/license b/license
new file mode 100644
index 0000000..bd098ed
--- /dev/null
+++ b/license
@@ -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()