From 7b9bc9a8703fa3fe6687902fe1f3d8ecb74a6b1e Mon Sep 17 00:00:00 2001 From: Dennis Fink Date: Mon, 14 Jul 2025 14:56:56 +0200 Subject: Better documentation --- patternutils/commands/patterncfg.py | 50 +++++++++++++---------- patternutils/commands/pjson.py | 3 -- patternutils/commands/pmatch.py | 46 +++++++++++++++------ patternutils/commands/pmediainfo.py | 8 ++-- patternutils/commands/prss.py | 10 +++-- patternutils/commands/targs.py | 80 +++++++++++++++++++++++-------------- patternutils/commands/tln.py | 24 ++++++++--- patternutils/commands/tmv.py | 53 +++++++++++++++--------- patternutils/config.py | 48 ++++++++++++++++++---- patternutils/match.py | 12 ++++++ patternutils/template.py | 19 +++++++++ patternutils/utils.py | 71 ++++++++++++++++++++++++++------ 12 files changed, 306 insertions(+), 118 deletions(-) diff --git a/patternutils/commands/patterncfg.py b/patternutils/commands/patterncfg.py index 2d1fd4f..3c75711 100644 --- a/patternutils/commands/patterncfg.py +++ b/patternutils/commands/patterncfg.py @@ -27,44 +27,51 @@ CURRENT_SUPPORTED_PROGRAMS: tuple[config.CommandNames, ...] = ( @click.group(context_settings={"help_option_names": ("-h", "--help", "-?")}) def patterncfg() -> None: + """CLI tool for managing patternutils templates.""" pass -@patterncfg.command(name="init", help="initialize config folders") +@patterncfg.command(name="init", help="Initialize config folders") def init() -> None: - for dir in itertools.chain(CURRENT_SUPPORTED_PROGRAMS, ("globals",)): - os.makedirs(os.path.join(config.CONFIG_PATH, dir), exist_ok=True) + """Creates necessary config directories if they do not exist.""" + for directory in itertools.chain(CURRENT_SUPPORTED_PROGRAMS, ("globals",)): + os.makedirs(os.path.join(config.CONFIG_PATH, directory), exist_ok=True) -@patterncfg.command(name="list", help="list all patternutils template files") +@patterncfg.command(name="list", help="List all patternutils template files") def list() -> None: + """Lists all available template files in the config directory.""" try: - for d in os.listdir(config.CONFIG_PATH): - click.secho(d, fg="blue") - for f in os.listdir(os.path.join(config.CONFIG_PATH, d)): - click.echo(f"└── {f}") + for directory in os.listdir(config.CONFIG_PATH): + click.secho(directory, fg="blue") + for filename in os.listdir(os.path.join(config.CONFIG_PATH, directory)): + click.echo(f"└── {filename}") except FileNotFoundError as e: click.secho( - f"{e.filename} not found! Please run patterncfg init first.", + f"Config directory '{config.CONFIG_PATH}' not found! Run 'patterncfg init' first.", fg="red", err=True, ) raise SystemExit(1) -@patterncfg.command(name="add", help="add new template file") +@patterncfg.command(name="add", help="Add a new template file") @click.option( "-t", "--template-engine", default=None, type=click.Choice(("python", "jinja2")), - help="select the template engine to use. Only applicable for tmv, targs and tln", + help="Select the template engine (only applicable for tmv, targs, tln).", ) @click.argument("command", required=True, type=click.Choice(CURRENT_SUPPORTED_PROGRAMS)) @click.argument("name") def add( - template_engine: Optional[template.TemplateEngines], command: str, name: str + template_engine: Optional[template.TemplateEngines], + command: str, + name: str, ) -> None: + """Creates a new template file for a given command.""" + fileext: template.TemplateEngines | Literal["regex"] | None = template_engine if command == "pmatch": fileext = "regex" @@ -73,17 +80,17 @@ def add( click.edit(filename=filename) -@patterncfg.command(name="remove", help="remove template file") +@patterncfg.command(name="remove", help="Remove a template file") @click.option( "-c", "--command", default=None, type=click.Choice(CURRENT_SUPPORTED_PROGRAMS), - help="select the command from which to remove the file", + help="Specify the command from which to remove the file.", ) @click.argument("name") def remove(command: Optional[config.CommandNames], name: str) -> None: - + """Removes a specified template file.""" files = config.find_templates(name, command) if not files: @@ -106,22 +113,24 @@ def remove(command: Optional[config.CommandNames], name: str) -> None: os.remove(files[0]) -@patterncfg.command(name="view", help="view template file") +@patterncfg.command(name="view", help="View a template file") @click.option( "-c", "--command", default=None, type=click.Choice(CURRENT_SUPPORTED_PROGRAMS), - help="select the command from which to view the file", + help="Specify the command from which to view the file.", ) @click.option( "--color/--no-color", is_flag=True, default=has_pygments, - help="Colorize output using pygments", + help="Colorize output using pygments if available.", ) @click.argument("name") def view(name: str, command: config.CommandNames, color: bool) -> None: + """Displays the contents of a template file.""" + files = config.find_templates(name, command) if not files: @@ -164,16 +173,17 @@ def view(name: str, command: config.CommandNames, color: bool) -> None: click.echo(text[:-1]) -@patterncfg.command(name="edit", help="edit a template file") +@patterncfg.command(name="edit", help="Edit a template file") @click.option( "-c", "--command", default=None, type=click.Choice(CURRENT_SUPPORTED_PROGRAMS), - help="select the command from which to edit the file", + help="Specify the command from which to edit the file.", ) @click.argument("name") def edit(name: str, command: config.CommandNames) -> None: + """Opens a template file in an editor.""" files = config.find_templates(name, command) if not files: diff --git a/patternutils/commands/pjson.py b/patternutils/commands/pjson.py index cbc87c9..de300cb 100644 --- a/patternutils/commands/pjson.py +++ b/patternutils/commands/pjson.py @@ -1,4 +1,3 @@ -import json import re from collections.abc import Iterable from typing import Any, Pattern, TextIO @@ -76,7 +75,6 @@ def pjson( human_readable: bool, verbose: bool, ) -> None: - try: regex_pattern_c = re.compile(regex_pattern) except re.error as e: @@ -97,7 +95,6 @@ def pjson( has_no_match = False has_empty_data = False for line in data: - if not line: has_empty_data = True click.secho(f"{line} has no input.", fg="red", err=True) diff --git a/patternutils/commands/pmatch.py b/patternutils/commands/pmatch.py index 166d941..62fc256 100644 --- a/patternutils/commands/pmatch.py +++ b/patternutils/commands/pmatch.py @@ -7,9 +7,8 @@ from typing import Generator, Iterator, Optional, Pattern import click -from .. import config +from .. import config, utils from .. import match as mat -from .. import utils def apply_regex( @@ -17,9 +16,19 @@ def apply_regex( regex_pattern: Pattern[str], match_full_path: bool, ) -> Generator[Optional[dict[str, str]], None, None]: + """Applies a regex pattern to directory entries and yields matched data. - for f in walk_function: - subject = os.path.abspath(f.path) if match_full_path else f.name + Args: + walk_function: An iterator over directory entries. + regex_pattern: A compiled regex pattern. + match_full_path: Whether to match against full absolute paths. + + Yields: + A dictionary containing match results, or None if no match is found. + """ + + for entry in walk_function: + subject = os.path.abspath(entry.path) if match_full_path else entry.name try: match = mat.apply(subject, regex_pattern) except ValueError: @@ -35,54 +44,64 @@ def apply_regex( @click.command(context_settings={"help_option_names": ("-h", "--help", "-?")}) -@click.option("-d", "--directory", multiple=True, default=["./"]) +@click.option( + "-d", + "--directory", + multiple=True, + default=["./"], + help="directories to search. Can be specified multiple times. Default: ./", +) @click.option( "-m", "--match-directories", is_flag=True, default=False, - help="search directory name also for matches", + help="Match on directories as well.", ) @click.option( "-r", "--recursive", is_flag=True, default=False, - help="search directories recursively", + help="Search directories recursively.", ) @click.option( "--abort-on-no-match", is_flag=True, default=False, - help="exit if a name does not match the INPUT_PATTERN", + help="Exit if a name does not match the INPUT_PATTERN.", ) @click.option( "--match-full-path", is_flag=True, default=False, - help="match on full absolute path instead of only the filename", + help="Match on full absolute path instead of only the filename.", ) @click.option( "-p", "--use-predefined-pattern", is_flag=True, default=False, - help="use predefined regex pattern", + help="Use predefined regex pattern.", ) @click.option( "--stream", is_flag=True, default=False, - help="Output a stream of objects instead a list of objects", + help="Output a stream of objects instead a list.", ) @click.option( "--human-readable", is_flag=True, default=False, - help="output the JSON in a human readable format", + help="Output the JSON in a human readable format.", ) @click.option( - "-v", "--verbose", is_flag=True, default=False, help="explain what is being done" + "-v", + "--verbose", + is_flag=True, + default=False, + help="Explain what is being done.", ) @click.version_option() @click.argument("regex-pattern") @@ -98,6 +117,7 @@ def pmatch( human_readable: bool, verbose: bool, ) -> None: + """Match the REGEX_PATTERN against files in specified directories and output JSON.""" if use_predefined_pattern: try: diff --git a/patternutils/commands/pmediainfo.py b/patternutils/commands/pmediainfo.py index 0fa7b18..35f9d4c 100644 --- a/patternutils/commands/pmediainfo.py +++ b/patternutils/commands/pmediainfo.py @@ -6,8 +6,6 @@ from typing import Any, Generator, Iterator, Optional import click from pymediainfo import MediaInfo -from .. import config -from .. import match as mat from .. import utils @@ -57,7 +55,11 @@ def apply_mediainfo( help="output the JSON in a human readable format", ) @click.option( - "-v", "--verbose", is_flag=True, default=False, help="explain what is being done" + "-v", + "--verbose", + is_flag=True, + default=False, + help="explain what is being done", ) @click.version_option() def pmediainfo( diff --git a/patternutils/commands/prss.py b/patternutils/commands/prss.py index 1ec4675..d4232ee 100644 --- a/patternutils/commands/prss.py +++ b/patternutils/commands/prss.py @@ -2,7 +2,7 @@ import functools import itertools import os import os.path -from typing import Any, Generator, Iterator, Optional +from typing import Any, Generator, Optional import click @@ -20,7 +20,6 @@ def match( walk_function: functools.partial[Generator[os.DirEntry[Any], None, None]], directory: list[str], ) -> Generator[Optional[dict[str, str]], None, None]: - direntries = [] filenames = [] @@ -90,7 +89,11 @@ def match( help="output the JSON in a human readable format", ) @click.option( - "-v", "--verbose", is_flag=True, default=False, help="explain what is being done" + "-v", + "--verbose", + is_flag=True, + default=False, + help="explain what is being done", ) @click.version_option() @click.argument("url") @@ -103,7 +106,6 @@ def prss( human_readable: bool, verbose: bool, ) -> None: - try: parsed_feed = feedparser.parse(url) except Exception: diff --git a/patternutils/commands/targs.py b/patternutils/commands/targs.py index c0bfeeb..85acade 100644 --- a/patternutils/commands/targs.py +++ b/patternutils/commands/targs.py @@ -1,7 +1,5 @@ import functools -import json import multiprocessing -import os import shlex import subprocess from collections.abc import Iterable @@ -17,60 +15,89 @@ def run_command( run: functools.partial[subprocess.CompletedProcess[str]] | Callable[..., None], shell: bool, ) -> None: + """Executes a given command using the provided run function. + + Args: + command: The command string to execute. + run: A callable function for execution (e.g., subprocess.run or print). + shell: Whether to run the command in a shell. + """ run(shlex.split(command) if not shell else command) @click.command(context_settings={"help_option_names": ("-h", "--help", "-?")}) -@click.option("--read-from", default="-", type=click.File("r")) @click.option( - "-s", "--shell", is_flag=True, default=False, help="run command in a shell" + "--read-from", + default="-", + type=click.File("r"), + help="Read input from file or stdin.", +) +@click.option( + "-s", + "--shell", + is_flag=True, + default=False, + help="Run command in a shell.", ) @click.option( "-t", "--template-engine", default="python", type=click.Choice(["python", "jinja2"]), - help="select the template engine to use", + help="Select the template engine to use.", +) +@click.option( + "-v", + "--verbose", + is_flag=True, + default=False, + help="Explain what is being done.", ) @click.option( - "-v", "--verbose", is_flag=True, default=False, help="explain what is being done" + "-P", + "--max-procs", + default=1, + type=click.IntRange(min=0, clamp=True), + help="Max parallel processes.", ) -@click.option("-P", "--max-procs", default=1, type=click.IntRange(min=0, clamp=True)) @click.option( "-p", "--use-predefined-template", is_flag=True, default=False, - help="use predefined template", + help="Use a predefined template.", ) @click.option( "--redirect-stdout", is_flag=True, default=False, - help="Redirect stdout to /dev/null", + help="Redirect stdout to /dev/null.", ) @click.option( "--redirect-stderr", is_flag=True, default=False, - help="Redirect stderr to /dev/null", + help="Redirect stderr to /dev/null.", ) @click.option( - "--stream", is_flag=True, default=False, help="Input is a stream of JSON objects" + "--stream", + is_flag=True, + default=False, + help="Input is a stream of JSON objects.", ) @click.option( "-e", "--editor", is_flag=True, default=False, - help="Open an editor before running commands to allow manual changes", + help="Open an editor before running commands to allow manual changes.", ) @click.option( "-n", "--dry-run", is_flag=True, default=False, - help="perform a trial run without actually executing the command", + help="Perform a trial run without executing commands.", ) @click.version_option() @click.argument("command") @@ -126,13 +153,13 @@ def targs( try: if (new_commands := utils.edit(commands)) is not None: commands = list(new_commands.values()) - except RuntimeError: click.secho("Your edit is not parseable!", fg="red", err=True) raise SystemExit(1) stdout = subprocess.DEVNULL if redirect_stdout else None stderr = subprocess.DEVNULL if redirect_stderr else None + patched_subprocess = functools.partial( subprocess.run, shell=shell, @@ -140,27 +167,20 @@ def targs( stderr=stderr, ) - if not dry_run: - patched_run_command = functools.partial( - run_command, - run=patched_subprocess, - shell=shell, - ) - else: - patched_run_command = functools.partial( - run_command, - run=print, - shell=True, - ) + execute_command = functools.partial( + run_command, + run=print if dry_run else patched_subprocess, + shell=True if dry_run else shell, + ) max_procs = utils.max_procs() if max_procs == 0 else max_procs if max_procs == 1: - for r in commands: - patched_run_command(r) + for cmd in commands: + execute_command(cmd) else: - with multiprocessing.Pool(max_procs) as p: - p.map(patched_run_command, commands) + with multiprocessing.Pool(max_procs) as pool: + pool.map(execute_command, commands) if __name__ == "__main__": diff --git a/patternutils/commands/tln.py b/patternutils/commands/tln.py index 8d58342..f76898d 100644 --- a/patternutils/commands/tln.py +++ b/patternutils/commands/tln.py @@ -1,4 +1,3 @@ -import json import os import os.path from collections.abc import Iterable @@ -56,10 +55,16 @@ def link_file(old_filename: str, new_filename: str, symbolic: bool) -> None: help="select the template engine to use", ) @click.option( - "-k", "--key", default="_abspath", help="select the key to use as the source file" + "-k", + "--key", + default="_abspath", + help="select the key to use as the source file", ) @click.option( - "--stream", is_flag=True, default=False, help="Input is a stream of JSON objects" + "--stream", + is_flag=True, + default=False, + help="Input is a stream of JSON objects", ) @click.option( "-p", @@ -68,7 +73,11 @@ def link_file(old_filename: str, new_filename: str, symbolic: bool) -> None: default=False, help="use predefined template", ) -@click.option("--read-from", default="-", type=click.File("r")) +@click.option( + "--read-from", + default="-", + type=click.File("r"), +) @click.option( "--abort-on-path-exist", is_flag=True, @@ -83,7 +92,11 @@ def link_file(old_filename: str, new_filename: str, symbolic: bool) -> None: help="perform a trial run with no changes made", ) @click.option( - "-v", "--verbose", is_flag=True, default=False, help="explain what is being done" + "-v", + "--verbose", + is_flag=True, + default=False, + help="explain what is being done", ) @click.version_option() @click.argument("output-pattern") @@ -102,7 +115,6 @@ def tln( dry_run: bool, verbose: bool, ) -> None: - data: Iterable[dict[str, Any]] if stream: data = map(utils.json_loads, read_from) diff --git a/patternutils/commands/tmv.py b/patternutils/commands/tmv.py index e9c4721..4fda28c 100644 --- a/patternutils/commands/tmv.py +++ b/patternutils/commands/tmv.py @@ -1,4 +1,3 @@ -import json import os.path import shutil from collections.abc import Iterable @@ -10,7 +9,7 @@ from .. import config, template, utils def move_file(old_filename: str, new_filename: str) -> None: - # Create new directories if needed + """Moves a file to a new location, creating necessary directories if needed.""" dirs = os.path.dirname(new_filename) if dirs: os.makedirs(dirs, exist_ok=True) @@ -24,56 +23,74 @@ def move_file(old_filename: str, new_filename: str) -> None: "--interactive", is_flag=True, default=False, - help="prompt before renaming files", + help="Prompt before renaming files.", ) @click.option( "-e", "--editor", is_flag=True, default=False, - help="Open an editor before renaming files to allow manual changes", + help="Open an editor before renaming files to allow manual changes.", +) +@click.option( + "-f", + "--force", + is_flag=True, + default=False, + help="Force rename, even if a file exists.", ) -@click.option("-f", "--force", is_flag=True, default=False, help="force rename") @click.option( "-t", "--template-engine", default="python", type=click.Choice(["python", "jinja2"]), - help="select the template engine to use", + help="Select the template engine to use.", ) @click.option( - "-k", "--key", default="_abspath", help="select the key to use as the source file" + "-k", + "--key", + default="_abspath", + help="Select the key to use as the source file.", ) @click.option( "-s", "--stream", is_flag=True, default=False, - help="Input is a stream of JSON objects", + help="Read input as stream of JSON objects.", ) @click.option( "-p", "--use-predefined-template", is_flag=True, default=False, - help="use predefined template", + help="Use a predefined template.", +) +@click.option( + "--read-from", + default="-", + type=click.File("r"), + help="Input file (default: stdin)", ) -@click.option("--read-from", default="-", type=click.File("r")) @click.option( "--abort-on-path-exist", is_flag=True, default=False, - help="exit if a renamed path already exists", + help="Exit if a renamed path already exists.", ) @click.option( "-n", "--dry-run", is_flag=True, default=False, - help="perform a trial run with no changes made", + help="Perform a trial run without making changes.", ) @click.option( - "-v", "--verbose", is_flag=True, default=False, help="explain what is being done" + "-v", + "--verbose", + is_flag=True, + default=False, + help="Explain what is being done.", ) @click.version_option() @click.argument("output-pattern") @@ -93,11 +110,11 @@ def tmv( ) -> None: """Rename files based on the OUTPUT_PATTERN. - It reads in a JSON list containing objects or a stream of objects (if the - --stream option is used). The object needs to contain a key, named as - the specified option KEY (default: _abspath), which points to the file to - be moved. The rest of the keys will be supplied to the templating engine to - generate the new filename. + Reads a JSON list containing objects or a stream of JSON objects (if + --stream is used). The object must contain a key (specified via --key, + default: _abspath) that points to the file to be renamed. The rest of the + keys will be supplied to the templating engine to generate the new + filename. """ data: Iterable[dict[str, Any]] diff --git a/patternutils/config.py b/patternutils/config.py index 2ede863..fab41be 100644 --- a/patternutils/config.py +++ b/patternutils/config.py @@ -29,7 +29,20 @@ def load_template( def load_template( template_name: str, command_name: Optional[CommandNames] ) -> tuple[str, TemplateExtensions]: - """Load a template from the config directory.""" + """Load a template from the config directory. + + Args: + template_name: The name of the template file (without extension). + command_name: The command requesting the template. + + Returns: + A tuple containing the template content as a string and its engine type. + + Raises: + FileNotFoundError: If no matching template is found. + RuntimeError: If multiple templates are found for the given name. + ValueError: If the extracted template extension is invalid. + """ files = find_templates(template_name, command_name) if len(files) == 1: @@ -42,14 +55,32 @@ def load_template( raise RuntimeError("Multiple files found!") output_pattern = template_lines.replace("\n", "") - template_engine = cast(TemplateExtensions, os.path.splitext(template_file)[1][1:]) + extension = os.path.splitext(template_file)[1][1:].lower() + if extension not in ("regex", "python", "jinja2"): + raise ValueError( + f"Invalid template extension '{extension}' in '{template_file}'." + ) + + template_engine = cast(TemplateExtensions, extension) return output_pattern, template_engine def find_templates( template_name: str, command_name: Optional[CommandNames] = None ) -> list[str]: + """Finds matching template files based on the template name and command. + + Args: + template_name: The name of the template file (without extension). + command_name: The command requesting the template. + + Returns: + A list of matching template file paths. + + Raises: + ValueError: If `command_name` is invalid. + """ if command_name is None: # CONFIG_PATH/*/TEMPLATE_NAME.* @@ -68,10 +99,11 @@ def find_templates( else: raise ValueError(f"Unknown command '{command_name}'.") - regex = re.compile( - os.path.join(CONFIG_PATH, f"{path_sub_regex}", f"{template_name}.{extension}") + pattern = re.compile( + os.path.join(CONFIG_PATH, path_sub_regex, f"{template_name}.{extension}") ) - - files = filter(regex.search, glob.iglob(os.path.join(CONFIG_PATH, "*", "*"))) - - return list(files) + return [ + file + for file in glob.iglob(os.path.join(CONFIG_PATH, "*", "*")) + if pattern.search(file) + ] diff --git a/patternutils/match.py b/patternutils/match.py index 6120594..9ff49f9 100644 --- a/patternutils/match.py +++ b/patternutils/match.py @@ -2,6 +2,18 @@ from typing import Pattern def apply(subject: str, regex_pattern: Pattern[str]) -> dict[str, str]: + """Applies a regex pattern to a subject string and extracts named groups. + + Args: + subject: The string to be matched. + regex_pattern: A compiled regex pattern. + + Returns: + A dictionary containing matched named groups, including `_subject`. + + Raises: + ValueError: If the subject does not match the regex pattern. + """ if (match := regex_pattern.search(subject)) is not None: groupdict = match.groupdict() groupdict["_subject"] = subject diff --git a/patternutils/template.py b/patternutils/template.py index d918e15..c02b687 100644 --- a/patternutils/template.py +++ b/patternutils/template.py @@ -15,24 +15,31 @@ else: jinja_env = jinja2.Environment() def to_datetime(value: str, format: str = "%Y-%m-%d") -> datetime: + """Converts a string to a datetime object.""" return datetime.strptime(value, format) def format_datetime(value: datetime, format: str = "%Y-%m-%d") -> str: + """Formats a datetime object as a string.""" return value.strftime(format) def to_int(value: str, base: int = 10) -> int: + """Converts a string to an integer.""" return int(value, base=base) def to_float(value: str) -> float: + """Converts a string to a float.""" return float(value) def quote(value: str) -> str: + """Quotes a string safely for shell execution.""" return shlex.quote(value) def splitpath(value: str) -> tuple[str, str]: + """Splits a path into (directory, filename).""" return os.path.split(value) def splitext(value: str) -> tuple[str, str]: + """Splits a file path into (root, extension).""" return os.path.splitext(value) jinja_env.filters["datetime"] = to_datetime @@ -47,6 +54,18 @@ else: def get_render_function( template: str, *, engine: TemplateEngines = "python" ) -> Callable[..., str]: + """Returns a function that renders a template using the specified engine. + + Args: + template: The template string. + engine: The template engine to use ('python' or 'jinja2'). + + Returns: + A function that takes parameters and renders the template. + + Raises: + RuntimeError: If Jinja2 is selected but not installed. + """ if engine == "jinja2": if jinja_feature: env_template = jinja_env.from_string(template) diff --git a/patternutils/utils.py b/patternutils/utils.py index fed3046..85734db 100644 --- a/patternutils/utils.py +++ b/patternutils/utils.py @@ -6,11 +6,20 @@ import click def max_procs() -> int: + """Returns the number of CPU cores available, or 1 if unavailable.""" cpu_count = os.cpu_count() return 1 if cpu_count is None else cpu_count * 2 def edit(data: dict[str, str] | list[str]) -> Optional[dict[str, str]]: + """Opens an interactive editor for modifying dictionary or list entries. + + Args: + data: A dictionary (keys are editable) or a list of strings. + + Returns: + A dictionary of modified entries or None if no changes were made. + """ longest = len(max(data, key=len)) @@ -38,15 +47,35 @@ def edit(data: dict[str, str] | list[str]) -> Optional[dict[str, str]]: def json_dumps(data: Any, human_readable: bool = False) -> str: - config: dict[str, Any] = ( - {"indent": 4, "sort_keys": True} - if human_readable - else {"separators": (",", ":")} + """Serializes data to JSON with optional human-readable formatting. + + Args: + data: The data to serialize. + human_readable: Whether to pretty-print the JSON. + + Returns: + A JSON string. + """ + return json.dumps( + data, + indent=4 if human_readable else None, + sort_keys=human_readable, + separators=(", ", ": ") if not human_readable else None, ) - return json.dumps(data, **config) def json_loads(data: Any) -> Any: + """Deserializes a JSON string into a Python object. + + Args: + data: A JSON string. + + Returns: + The deserialized Python object. + + Raises: + SystemExit: If the JSON is invalid. + """ try: return json.loads(data) except json.JSONDecodeError as e: @@ -57,11 +86,27 @@ def json_loads(data: Any) -> Any: def walk( directory: str, recursive: bool = False, match_directories: bool = False ) -> Generator[os.DirEntry[str], None, None]: - for entry in os.scandir(directory): - is_dir = entry.is_dir() - if recursive and is_dir: - yield from walk(entry.path, recursive, match_directories) - elif entry.is_file() or (match_directories and is_dir): - yield entry - else: - continue + """Yields directory entries, optionally recursing into subdirectories. + + Args: + directory: The root directory to scan. + recursive: Whether to recurse into subdirectories. + match_directories: Whether to include directories in the results. + + Yields: + os.DirEntry objects matching the criteria. + """ + try: + for entry in os.scandir(directory): + if entry.is_dir(): + if recursive: + yield from walk(entry.path, recursive, match_directories) + if match_directories: + yield entry + elif entry.is_file(): + yield entry + else: + continue + except FileNotFoundError: + click.secho(f"Directory not found: {directory}", fg="red", err=True) + raise SystemExit(1) -- cgit v1.3.1