Source code for rmote.tools.fs

import difflib
import re
from enum import IntEnum
from pathlib import Path

from rmote.protocol import Tool


[docs] class LineInFileMatch(IntEnum): """Controls how regexp matches are replaced by :meth:`FileSystem.line_in_file`. Attributes: FIRST: Replace only the first line that matches the regexp. ALL: Replace every line that matches the regexp. """ FIRST = 0 ALL = 1
[docs] class FileSystem(Tool): """Remote filesystem operations - read, glob, and idempotent line-in-file."""
[docs] @staticmethod def read_bytes(path: str) -> bytes: """Read *path* and return its raw contents. Args: path: Absolute or relative path on the remote filesystem. Returns: File contents as :class:`bytes`. """ return Path(path).read_bytes()
[docs] @staticmethod def read_str(path: str) -> str: """Read *path* and return its contents decoded as UTF-8. Args: path: Absolute or relative path on the remote filesystem. Returns: File contents as :class:`str`. """ return Path(path).read_text()
[docs] @staticmethod def glob(path: str | Path, pattern: str) -> list[str]: """Return all paths under *path* that match *pattern*. Args: path: Directory to search in. pattern: Glob pattern relative to *path* (e.g. ``"*.conf"``). Returns: Sorted list of matching paths as strings. """ return list(map(str, Path(path).glob(pattern)))
[docs] @staticmethod def line_in_file( path: str, *, line: str, regexp: str | None = None, strip: bool = True, create: bool = False, match: LineInFileMatch = LineInFileMatch.FIRST, ) -> str: """Ensure *line* is present in the file at *path*, idempotently. If *regexp* is given, replace the first (or all, with ``match=ALL``) lines that match the pattern with *line*. If no line matches, or if *regexp* is not given and *line* is not found, *line* is appended. Args: path: Path to the file to modify. line: The desired line content to insert or substitute. regexp: A regex pattern to match against existing lines. When a match is found, the matching line(s) are replaced with *line*. strip: Compare lines after stripping whitespace when looking for an exact match (no *regexp*). Default ``True``. create: Create the file if it does not exist. Default ``False``. match: Whether to replace the :attr:`~LineInFileMatch.FIRST` matching line or :attr:`~LineInFileMatch.ALL` matching lines. Returns: A unified diff string describing the change, or an empty string if the file was already in the desired state. Raises: FileNotFoundError: If *path* does not exist and *create* is ``False``. """ p = Path(path) if not p.exists(): if create: p.touch() else: raise FileNotFoundError(f"File {p} does not exist") original = p.read_text() lines = original.splitlines() replaced = 0 if regexp is not None: pattern = re.compile(regexp) for i, file_line in enumerate(lines): if pattern.search(file_line): lines[i] = line replaced += 1 if match == LineInFileMatch.FIRST: break else: target = line.strip() if strip else line for file_line in lines: if (file_line.strip() if strip else file_line) == target: return "" # already present if not replaced: lines.append(line) trailing = "\n" if original.endswith("\n") else "" new_content = "\n".join(lines) + trailing if new_content == original: return "" p.write_text(new_content) return "".join( difflib.unified_diff( original.splitlines(keepends=True), new_content.splitlines(keepends=True), fromfile=str(p), tofile=str(p), ) )