summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--patternutils/commands/patterncfg.py50
-rw-r--r--patternutils/commands/pjson.py3
-rw-r--r--patternutils/commands/pmatch.py46
-rw-r--r--patternutils/commands/pmediainfo.py8
-rw-r--r--patternutils/commands/prss.py10
-rw-r--r--patternutils/commands/targs.py80
-rw-r--r--patternutils/commands/tln.py24
-rw-r--r--patternutils/commands/tmv.py53
-rw-r--r--patternutils/config.py48
-rw-r--r--patternutils/match.py12
-rw-r--r--patternutils/template.py19
-rw-r--r--patternutils/utils.py71
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)