diff options
| -rw-r--r-- | doc/api/index.rst | 2 | ||||
| -rw-r--r-- | doc/conf.py | 2 | ||||
| -rw-r--r-- | doc/dev/index.rst | 4 | ||||
| -rw-r--r-- | doc/dev/processor.rst | 114 | ||||
| -rw-r--r-- | patchtree/cli.py | 13 | ||||
| -rw-r--r-- | patchtree/config.py | 49 | ||||
| -rw-r--r-- | patchtree/context.py | 71 | ||||
| -rw-r--r-- | patchtree/diff.py | 7 |
8 files changed, 164 insertions, 98 deletions
diff --git a/doc/api/index.rst b/doc/api/index.rst index b4678f8..feb1740 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -1,3 +1,5 @@ +.. _api: + API reference ============= diff --git a/doc/conf.py b/doc/conf.py index 07823d6..0dc01c8 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -18,7 +18,7 @@ extensions = [ ] templates_path = [] exclude_patterns = [] -html_theme = "alabaster" +html_theme = "sphinx_rtd_theme" html_static_path = [] autodoc_mock_imports = [project] diff --git a/doc/dev/index.rst b/doc/dev/index.rst index 66bd679..523e6a9 100644 --- a/doc/dev/index.rst +++ b/doc/dev/index.rst @@ -109,6 +109,10 @@ The configuration file is a Python file sourced from ``ptconfig.py`` relative to This file is a regular Python source file and can contain any arbitrary code. Any global definitions with an identical name to a member variable of the :any:`Config` class will override the global configuration instance's value. +The main method in which patchtree is configured is by creating subclasses of its internal classes and overriding its methods. +In order to facilitate this, the type of most classes is read from the :any:`Config` dataclass instead of being instantiated directly. +Please take a look at the :ref:`api` for more info. + For example: .. code:: none diff --git a/doc/dev/processor.rst b/doc/dev/processor.rst index 0a19ab8..018d216 100644 --- a/doc/dev/processor.rst +++ b/doc/dev/processor.rst @@ -16,28 +16,14 @@ Note that some processors may take positional arguments, while others may use ke Identity ******** -The identity processor is used to "touch" files or add arbitrary identifiers to patchset source filenames through its arguments. +The identity processor is used to "touch" files, change the mode of existing files, or add arbitrary identifiers to patchset source filenames by passing arbitrary arguments. -.. list-table:: - :stub-columns: 1 - - * - Class - - :any:`ProcessIdentity` - * - Identifier - - ``id`` - -Input - Ignored. - -Output - The *content* of the target file. - - .. note:: - - Changing the patchset input's mode *will* affect the target file mode! - -Arguments - Any arguments passed to this processor are ignored. +:Class: :any:`ProcessIdentity` +:Identifier: ``id`` +:Input: Ignored. +:Output: + A file with the *content* of the target file and *mode* of the patchset input. +:Arguments: Any arguments passed to this processor are ignored. .. _process_cocci: @@ -51,22 +37,11 @@ The Coccinelle processor uses Coccinelle to apply patch(es) in the SmPL (Semanti In order to use this processor, Coccinelle must be installed and ``spatch`` must be available in ``$PATH``. -.. list-table:: - :stub-columns: 1 - - * - Class - - :any:`ProcessCoccinelle` - * - Identifier - - ``cocci`` - -Input - Coccinelle's SmPL input. - -Output - The contents of the target file after being processed by Coccinelle (not the diff returned by Coccinelle). - -Arguments - Reserved. +:Class: :any:`ProcessCoccinelle` +:Identifier: ``cocci`` +:Input: Coccinelle's SmPL input. +:Output: The contents of the target file after being processed by Coccinelle (not the diff returned by Coccinelle). +:Arguments: Reserved. .. _process_jinja: @@ -74,30 +49,19 @@ Arguments Jinja template ************** -The Jinja processor passes the inputs through the Jinja2 templating engine. +The Jinja processor passes the input through the Jinja2 templating engine. + +:Class: :any:`ProcessJinja2` +:Identifier: ``jinja`` +:Input: Jinja template code. +:Output: The input after being processed by Jinja. +:Arguments: Reserved. .. note:: Template variables are generated through the :any:`get_template_vars <ProcessJinja2.get_template_vars>` method. This method returns an empty dict by default, and is meant to be implemented by implementing a custom class that derives from ProcessJinja2 and registering it through the :ref:`configuration file <ptconfig>`. -.. list-table:: - :stub-columns: 1 - - * - Class - - :any:`ProcessJinja2` - * - Identifier - - ``jinja`` - -Input - Jinja template code. - -Output - The input after being processed by Jinja. - -Arguments - Reserved. - .. _process_exe: ********** @@ -106,26 +70,17 @@ Executable The executable processor runs the input as an executable, passes the target file to its standard input, and returns its standard output. -.. list-table:: - :stub-columns: 1 - - * - Class - - :any:`ProcessExec` - * - Identifier - - ``exec`` - -Input +:Class: :any:`ProcessExec` +:Identifier: ``exec`` +:Input: Executable script. .. important:: - The executable must contain a shebang line to specify what interpreter to use. - -Output - Any content written to the standard output by the executable. + The executable *must* contain a shebang line to specify what interpreter to use. -Arguments - Reserved. +:Output: Any content written to the standard output by the executable. +:Arguments: Reserved. .. _process_merge: @@ -135,21 +90,12 @@ Merge The merge processor merges the input with the target file, such that changes are combined with the target instead of replacing the target. -.. list-table:: - :stub-columns: 1 - - * - Class - - :any:`ProcessMerge` - * - Identifier - - ``merge`` - -Input - Content to merge. - -Output - Merged changes. +:Class: :any:`ProcessMerge` +:Identifier: ``merge`` +:Input: Content to merge. +:Output: Merged changes. +:Arguments: Positional. -Arguments (positional) 1. Merge strategy: ``ignore`` diff --git a/patchtree/cli.py b/patchtree/cli.py index bef0c0d..c5672f0 100644 --- a/patchtree/cli.py +++ b/patchtree/cli.py @@ -8,6 +8,11 @@ from .config import Config def load_config() -> Config: + """ + Create a new instance of the :any:`Config` dataclass and attempt to overwrite any members using the global + variables defined in ptconfig.py (if it exists). + """ + init = {} cfg = Path("ptconfig.py") if cfg.exists(): @@ -18,6 +23,10 @@ def load_config() -> Config: def path_dir(path: str) -> Path: + """ + Argparse helper type for a pathlib Path to a directory. + """ + out = Path(path) if not out.is_dir(): raise ArgumentTypeError(f"not a directory: `{path}'") @@ -25,6 +34,10 @@ def path_dir(path: str) -> Path: def parse_arguments(config: Config) -> Context: + """ + Parse command-line arguments and return the application context. + """ + parser = config.argument_parser( prog="patchtree", description="patch file generator", diff --git a/patchtree/config.py b/patchtree/config.py index 2f2373e..48f11fb 100644 --- a/patchtree/config.py +++ b/patchtree/config.py @@ -21,14 +21,24 @@ DEFAULT_PROCESSORS: dict[str, type[Process]] = { class Header: """ - Patch output header. + Patch output header generator. + + The header is formatted as + + * shebang (optional) + * patchtree version info + * extra version info (empty by default) + * license (empty by default) """ config: Config context: Context name = "patchtree" + """Program name shown in version info.""" + license = None + """License text (optional).""" def __init__(self, config: Config, context: Context): self.config = config @@ -40,8 +50,13 @@ class Header: self.write_license() def write_shebang(self): + """ + Write a shebang line to apply the output patch if the --shebang option was passed. + """ + if not self.config.output_shebang: return + cmd = [ "/usr/bin/env", "-S", @@ -51,13 +66,28 @@ class Header: self.context.output.write(f"#!{cmdline}\n") def write_version(self): + """ + Write the patchtree name and version number. + """ + version = metadata.version("patchtree") self.context.output.write(f"{self.name} output (version {version})\n") def write_version_extra(self): + """ + Write extra version information (empty). + + This method is meant to be implemented by subclasses of Header defined in the ptconfig.py of + patchsets. + """ + pass def write_license(self): + """ + Write a license if it is defined. + """ + if self.license is None: return self.context.output.write(f"{self.license}\n") @@ -72,29 +102,32 @@ class Config: """ context: type[Context] = Context - """Context class type.""" + """Context class type. Override this to add custom context variables.""" patch: type[Patch] = Patch """Patch class type.""" argument_parser: type[ArgumentParser] = ArgumentParser - """ArgumentParser class type.""" + """ArgumentParser class type. Override this to add custom arguments.""" process_delimiter: str = "#" - """String used to delimit processors in patch source filenames.""" + """ + String used to delimit processors in patch source filenames. + + See: :ref:`processors`. + """ processors: dict[str, type[Process]] = field(default_factory=lambda: DEFAULT_PROCESSORS) """Maps processor specification string to :type:`Process` class type.""" header: type[Header] = Header - """Header class type.""" + """Header class type. Override this to modify the patch header format.""" diff_context: int = 3 - """Lines of context to include in the default diffs.""" + """Lines of context to include in the diffs.""" output_shebang: bool = False - """Whether to output a shebang line with the ``git patch`` command to apply - the patch.""" + """Whether to output a shebang line with the ``git patch`` command to apply the patch.""" default_patch_sources: list[Path] = field(default_factory=list) """List of default sources.""" diff --git a/patchtree/context.py b/patchtree/context.py index cca419a..ef32e97 100644 --- a/patchtree/context.py +++ b/patchtree/context.py @@ -18,22 +18,48 @@ ZIP_CREATE_SYSTEM_UNX = 3 class FS: + """Target filesystem interface.""" + target: Path def __init__(self, target: Path): self.target = target def get_dir(self, dir: str) -> list[str]: + """ + List all items in a subdirectory of the target. + + :returns: A list of all item names. + """ + raise NotImplementedError() def get_content(self, file: str) -> str | None: + """ + Get the content of a file relative to the target. + + :returns: + * The file content if it exists. + * None if the file does not exist. + """ + raise NotImplementedError() def get_mode(self, file: str) -> int: + """ + Get the mode of a file relative to the target. + + :returns: + * The mode as returned by stat(3)'s ``stat.st_mode`` + * 0 if the file does not exist + """ + raise NotImplementedError() class DiskFS(FS): + """Implementation of :any:`FS` for a regular directory. Reads directly from the disk.""" + def __init__(self, target): super(DiskFS, self).__init__(target) @@ -59,16 +85,30 @@ class DiskFS(FS): class ZipFS(FS): + """Implementation of :any:`FS` for zip files. Reads directly from the archive.""" + zip: ZipFile + """Underlying zip file.""" + files: dict[Path, ZipInfo] = {} + """Map of path -> ZipInfo for all files in the archive.""" def __init__(self, target): super(ZipFS, self).__init__(target) self.zip = ZipFile(str(target)) for info in self.zip.infolist(): self.files[Path(info.filename)] = info + # todo: index implicit directories in tree def get_info(self, path: str) -> ZipInfo | None: + """ + Get the ZipInfo for a file in the archive + + :returns: + * The ZipInfo for the file at ``path`` + * None if the file does not exist + """ + return self.files.get(Path(path), None) def get_dir(self, dir: str) -> list[str]: @@ -95,15 +135,40 @@ class ZipFS(FS): except: return "" + def is_implicit_dir(self, file: str) -> bool: + """ + Check if there is an implicit directory at ``file``. + + Some zip files may not include entries for all directories if they already define entries for files or + subdirectories within. This function checks if any path that is a subdirectory of ``file`` exists. + + :returns: ``True`` if there is a directory at ``file``, else ``False``. + """ + + parent = Path(file) + for child in self.files: + if parent in child.parents: + return True + return False + def get_mode(self, file: str) -> int: + MODE_NONEXISTANT = 0 + MODE_FILE = 0o644 | S_IFREG + MODE_DIR = 0o755 | S_IFDIR + info = self.get_info(file) if info is None: - return 0 + # if self.is_implicit_dir(file): + # return MODE_DIR + return MODE_NONEXISTANT + if info.create_system == ZIP_CREATE_SYSTEM_UNX: return (info.external_attr >> 16) & 0xFFFF + if info.is_dir(): - return 0o755 | S_IFDIR - return 0o644 | S_IFREG + return MODE_DIR + + return MODE_FILE class Context: diff --git a/patchtree/diff.py b/patchtree/diff.py index 7b55353..3dec600 100644 --- a/patchtree/diff.py +++ b/patchtree/diff.py @@ -19,15 +19,18 @@ class File: class Diff: """ - Produce a regular diff from the (possibly absent) original file to the file in the patch input tree. This - effectively overwrites whatever exists in the target sources with the file in the patch input tree. + Produce a regular diff from the (possibly absent) original file to the file in the patch input tree. """ config: Config file: str + """Path to file relative to target dir.""" a: File + """Original file.""" + b: File + """Target file.""" def __init__(self, config: Config, file: str): self.config = config |