makefiles-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
makefiles/__init__.py ADDED
File without changes
makefiles/_version.py ADDED
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '1.0.0'
21
+ __version_tuple__ = version_tuple = (1, 0, 0)
@@ -0,0 +1,80 @@
1
+ import argparse
2
+
3
+ import makefiles.types as custom_types
4
+
5
+
6
+ def get_parser() -> argparse.ArgumentParser:
7
+ parser: argparse.ArgumentParser = argparse.ArgumentParser(
8
+ prog="mkfile",
9
+ description="A lightweight Python utility for file creation and template generation from XDG_TEMPLATES_DIR",
10
+ )
11
+
12
+ parser.add_argument(
13
+ "files",
14
+ nargs="*",
15
+ action="store",
16
+ help="paths to files to create",
17
+ )
18
+
19
+ parser.add_argument(
20
+ "--version",
21
+ action="store_true",
22
+ help="print version and exit",
23
+ )
24
+
25
+ parser.add_argument(
26
+ "-t",
27
+ "--template",
28
+ nargs="?",
29
+ action="store",
30
+ type=str,
31
+ const=object(),
32
+ default=None,
33
+ help="template to generate. If no template is provided, it will prompt for template",
34
+ )
35
+
36
+ parser.add_argument(
37
+ "-p",
38
+ "--parents",
39
+ action="store_true",
40
+ help="make parents directories as needed",
41
+ )
42
+
43
+ parser.add_argument(
44
+ "-P",
45
+ "--picker",
46
+ nargs=1,
47
+ action="store",
48
+ type=str,
49
+ choices=["fzf", "manual"],
50
+ default=["manual"],
51
+ help="which template picker to use. If picker is `fzf`, fzf must be present in PATH. Default is `manual`",
52
+ )
53
+
54
+ parser.add_argument(
55
+ "-H",
56
+ "--height",
57
+ nargs=1,
58
+ action="store",
59
+ type=custom_types.NaturalNumber,
60
+ default=[custom_types.NaturalNumber(10)],
61
+ help="height of fzf window if fzf is used as template picker",
62
+ )
63
+
64
+ parser.add_argument(
65
+ "-l",
66
+ "--list",
67
+ action="store_true",
68
+ help="list available templates and exit",
69
+ )
70
+
71
+ return parser
72
+
73
+
74
+ def get_cli_args(argparser: argparse.ArgumentParser) -> argparse.Namespace:
75
+ cli_arguments: argparse.Namespace = argparser.parse_args()
76
+
77
+ if not cli_arguments.files and not (cli_arguments.version or cli_arguments.list):
78
+ argparser.error("the following arguments are required: files")
79
+
80
+ return cli_arguments
@@ -0,0 +1,82 @@
1
+ class MKFileException(Exception):
2
+ """Base exception for this project"""
3
+
4
+ def __init__(self, *args) -> None:
5
+ Exception.__init__(self, *args)
6
+
7
+
8
+ class InvalidPathError(MKFileException):
9
+ """Path is invalid"""
10
+
11
+ def __init__(self, *args) -> None:
12
+ MKFileException.__init__(self, *args)
13
+
14
+
15
+ class PathNotFoundError(MKFileException, FileNotFoundError):
16
+ """Path does not exists"""
17
+
18
+ def __init__(self, *args) -> None:
19
+ FileNotFoundError.__init__(self, *args)
20
+
21
+
22
+ class TemplateCreationError(MKFileException):
23
+ """Failed to create template"""
24
+
25
+ def __init__(self, *args) -> None:
26
+ MKFileException.__init__(self, *args)
27
+
28
+
29
+ class NoTemplatesAvailableError(MKFileException, FileNotFoundError):
30
+ """No template found"""
31
+
32
+ def __init__(self, *args) -> None:
33
+ FileNotFoundError.__init__(self, *args)
34
+
35
+
36
+ class TemplateNotFoundError(MKFileException, FileNotFoundError):
37
+ """Given template not found"""
38
+
39
+ def __init__(self, *args) -> None:
40
+ FileNotFoundError.__init__(self, *args)
41
+
42
+
43
+ class CopyError(MKFileException):
44
+ """Failed to copy file"""
45
+
46
+ def __init__(self, *args) -> None:
47
+ MKFileException.__init__(self, *args)
48
+
49
+
50
+ class InvalidSourceError(CopyError, InvalidPathError):
51
+ """Copy source is invalid"""
52
+
53
+ def __init__(self, *args) -> None:
54
+ CopyError.__init__(self, *args)
55
+
56
+
57
+ class SourceNotFoundError(CopyError, FileNotFoundError):
58
+ """Copy source does not exists"""
59
+
60
+ def __init__(self, *args) -> None:
61
+ FileNotFoundError.__init__(self, *args)
62
+
63
+
64
+ class DestinationExistsError(CopyError, FileExistsError):
65
+ """Copy destination already exists"""
66
+
67
+ def __init__(self, *args) -> None:
68
+ FileExistsError.__init__(self, *args)
69
+
70
+
71
+ class FZFError(MKFileException):
72
+ """Failed to run fzf"""
73
+
74
+ def __init__(self, *args) -> None:
75
+ MKFileException.__init__(self, *args)
76
+
77
+
78
+ class FZFNotFoundError(FZFError):
79
+ """fzf executable not found"""
80
+
81
+ def __init__(self, *args) -> None:
82
+ FZFError.__init__(self, *args)
makefiles/mkfile.py ADDED
@@ -0,0 +1,121 @@
1
+ import argparse
2
+ import os
3
+ import pathlib
4
+ import typing
5
+
6
+ import makefiles.cli_parser as cli_parser
7
+ import makefiles.exceptions as exceptions
8
+ import makefiles.types as custom_types
9
+ import makefiles.utils as utils
10
+ import makefiles.utils.cli_io as cli_io
11
+ import makefiles.utils.dirwalker as dirwalker
12
+ import makefiles.utils.fileutils as fileutils
13
+ import makefiles.utils.picker as picker
14
+
15
+ TEMPLATES_DIR: str = os.environ.get("XDG_TEMPLATES_DIR", f"{os.environ["HOME"]}/Templates")
16
+
17
+
18
+ def _get_available_templates(templates_dir: pathlib.Path) -> list[str]:
19
+ try:
20
+ available_templates: list[str] = dirwalker.listf(templates_dir)
21
+ if not available_templates:
22
+ raise exceptions.NoTemplatesAvailableError("no templates found")
23
+ except exceptions.InvalidPathError:
24
+ raise exceptions.NoTemplatesAvailableError("could not find template directory") from None
25
+
26
+ return available_templates
27
+
28
+
29
+ def _create_template(
30
+ template: str,
31
+ *destinations: pathlib.Path,
32
+ templates_dir: pathlib.Path,
33
+ overwrite: bool,
34
+ parents: bool,
35
+ ) -> custom_types.ExitCode:
36
+ exitcode: custom_types.ExitCode = custom_types.ExitCode(0)
37
+
38
+ template_path: pathlib.Path = templates_dir.joinpath(template)
39
+ try:
40
+ exitcode = fileutils.copy(template_path, *destinations, overwrite=overwrite, parents=parents) or exitcode
41
+ except exceptions.SourceNotFoundError:
42
+ raise exceptions.TemplateNotFoundError(f"template {template} not found") from None
43
+
44
+ return exitcode
45
+
46
+
47
+ def _get_template_from_prompt(
48
+ *,
49
+ t_picker: typing.Literal["fzf"] | typing.Literal["manual"],
50
+ fzf_height: custom_types.NaturalNumber = custom_types.NaturalNumber(10),
51
+ templates_dir: pathlib.Path,
52
+ ) -> str:
53
+ available_templates: list[str] = _get_available_templates(templates_dir)
54
+
55
+ if t_picker == "fzf":
56
+ return picker.fzf(available_templates, height=fzf_height)
57
+ elif t_picker == "manual":
58
+ return picker.manual(available_templates)
59
+
60
+
61
+ def runner(cli_arguments: argparse.Namespace, templates_dir: pathlib.Path) -> custom_types.ExitCode:
62
+ exitcode: custom_types.ExitCode = custom_types.ExitCode(0)
63
+
64
+ files: list[str] = cli_arguments.files
65
+ template: str | object | None = cli_arguments.template
66
+ t_picker: typing.Literal["fzf"] | typing.Literal["manual"] = cli_arguments.picker[0]
67
+ fzf_height: custom_types.NaturalNumber = cli_arguments.height[0]
68
+
69
+ if cli_arguments.version:
70
+ cli_io.print(f"{utils.get_version()}\n")
71
+ exitcode = custom_types.ExitCode(1)
72
+ return exitcode
73
+
74
+ if cli_arguments.list:
75
+ cli_io.print(f"{"\n".join(_get_available_templates(templates_dir))}\n")
76
+ return exitcode
77
+
78
+ files_paths: list[pathlib.Path] = list(map(pathlib.Path, files))
79
+
80
+ if not template:
81
+ exitcode = (
82
+ fileutils.create_empty_files(*files_paths, overwrite=False, parents=cli_arguments.parents) or exitcode
83
+ )
84
+ return exitcode
85
+
86
+ if not isinstance(template, str):
87
+ template = _get_template_from_prompt(
88
+ t_picker=t_picker,
89
+ fzf_height=fzf_height,
90
+ templates_dir=templates_dir,
91
+ )
92
+
93
+ # fmt:off
94
+ exitcode = _create_template(
95
+ template,
96
+ *files_paths,
97
+ templates_dir=templates_dir,
98
+ overwrite=False,
99
+ parents=cli_arguments.parents,
100
+ ) or exitcode
101
+ # fmt:on
102
+
103
+ return exitcode
104
+
105
+
106
+ def main() -> custom_types.ExitCode:
107
+ templates_dir_path: pathlib.Path = pathlib.Path(TEMPLATES_DIR)
108
+ exitcode: custom_types.ExitCode = custom_types.ExitCode(0)
109
+
110
+ argument_parser: argparse.ArgumentParser = cli_parser.get_parser()
111
+ cli_arguments: argparse.Namespace = cli_parser.get_cli_args(argument_parser)
112
+
113
+ try:
114
+ exitcode = runner(cli_arguments, templates_dir_path) or exitcode
115
+ except exceptions.MKFileException as ex:
116
+ cli_io.eprint(f"{argument_parser.prog}: {str(ex)}\n")
117
+ exitcode = custom_types.ExitCode(1)
118
+ except KeyboardInterrupt:
119
+ exitcode = custom_types.ExitCode(130)
120
+
121
+ return exitcode
makefiles/types.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class NaturalNumber(int):
5
+ """
6
+ A strict subclass of `int` that represents natural numbers (positive integers > 0).
7
+
8
+ This class enforces validation at instantiation time. Any attempt to create a
9
+ `NaturalNumber` with a non-integer, zero, or negative value will raise an error.
10
+
11
+ Examples:
12
+ >>> NaturalNumber(5)
13
+ 5
14
+ >>> NaturalNumber("3")
15
+ 3
16
+ >>> NaturalNumber(-1)
17
+ ValueError: NaturalNumber must be greater than 0, got -1
18
+ >>> NaturalNumber("abc")
19
+ TypeError: Invalid literal for NaturalNumber: 'abc'
20
+
21
+ Raises:
22
+ TypeError: If the input cannot be converted to an integer.
23
+ ValueError: If the integer is not greater than 0.
24
+ """
25
+
26
+ def __new__(cls, x, /) -> NaturalNumber:
27
+ if not isinstance(x, int):
28
+ try:
29
+ x = int(x)
30
+ except (TypeError, ValueError):
31
+ raise TypeError(f"Invalid literal for NaturalNumber: {x!r}") from None
32
+
33
+ if x <= 0:
34
+ raise ValueError(f"NaturalNumber must be greater than 0, got {x}")
35
+
36
+ return super().__new__(cls, x)
37
+
38
+
39
+ class ExitCode(int):
40
+ """
41
+ A strongly-typed representation of a POSIX-compliant process exit code.
42
+
43
+ This class ensures that only valid exit codes (unsigned 8-bit integers, 0–255)
44
+ can be instantiated. It inherits from `int`, so it can be used anywhere a regular
45
+ integer is accepted, such as in `sys.exit()` or `subprocess` interfaces.
46
+
47
+ Parameters:
48
+ x (int | str): The value to be validated and converted into a valid exit code.
49
+ Non-integer inputs are attempted to be cast to `int`.
50
+
51
+ Raises:
52
+ TypeError: If the input is not convertible to an integer.
53
+ ValueError: If the resulting integer is outside the range [0, 255].
54
+
55
+ Example:
56
+ >>> code = ExitCode(0)
57
+ >>> sys.exit(code)
58
+
59
+ >>> code = ExitCode("42")
60
+ >>> print(code) # 42
61
+
62
+ >>> ExitCode(999)
63
+ ValueError: ExitCode must be in range of [0,255], got 999
64
+ """
65
+
66
+ def __new__(cls, x, /) -> ExitCode:
67
+ if not isinstance(x, int):
68
+ try:
69
+ x = int(x)
70
+ except (TypeError, ValueError):
71
+ raise TypeError(f"Invalid literal for ExitCode: {x!r}") from None
72
+
73
+ if not (0 <= x <= 255):
74
+ raise ValueError(f"ExitCode must be in range of [0,255], got {x}")
75
+
76
+ return super().__new__(cls, x)
@@ -0,0 +1,137 @@
1
+ import os.path
2
+ import pathlib
3
+
4
+
5
+ def exists(path: pathlib.Path) -> bool:
6
+ """
7
+ Checks whether the specified path exists, including broken symlinks.
8
+
9
+ Args:
10
+ path (pathlib.Path): The path to check.
11
+
12
+ Returns:
13
+ bool: True if the path exists or is a broken symlink, False otherwise.
14
+ """
15
+ return os.path.lexists(path)
16
+
17
+
18
+ def isfile(path: pathlib.Path) -> bool:
19
+ """
20
+ Checks whether the given path is a regular file and not a symbolic link.
21
+
22
+ Args:
23
+ path (pathlib.Path): The path to evaluate.
24
+
25
+ Returns:
26
+ bool: True if the path exists, is a regular file, and is not a symlink; False otherwise.
27
+ """
28
+ return path.is_file() and not path.is_symlink()
29
+
30
+
31
+ def isdir(path: pathlib.Path) -> bool:
32
+ """
33
+ Checks whether the given path is a directory and not a symbolic link.
34
+
35
+ Args:
36
+ path (pathlib.Path): The path to evaluate.
37
+
38
+ Returns:
39
+ bool: True if the path exists, is a directory, and is not a symlink; False otherwise.
40
+ """
41
+ return path.is_dir() and not path.is_symlink()
42
+
43
+
44
+ def isbrokenlink(path: pathlib.Path) -> bool:
45
+ """
46
+ Checks whether the given path is a broken symbolic link.
47
+
48
+ Args:
49
+ path (pathlib.Path): The path to evaluate.
50
+
51
+ Returns:
52
+ bool: True if the path is a symlink and its target does not exist; False otherwise.
53
+
54
+ Note:
55
+ This relies on the fact that `path.exists()` returns False for broken symlinks.
56
+ """
57
+ # if path is a broken link, pathlib.Path.exists will return False. We will use this feature
58
+ return path.is_symlink() and not path.exists()
59
+
60
+
61
+ def islink(path: pathlib.Path) -> bool:
62
+ """
63
+ Checks whether the given path is a valid (non-broken) symbolic link.
64
+
65
+ Args:
66
+ path (pathlib.Path): The path to evaluate.
67
+
68
+ Returns:
69
+ bool: True if the path is a symlink and its target exists; False otherwise.
70
+ """
71
+ return path.is_symlink() and not isbrokenlink(path)
72
+
73
+
74
+ def islinkf(path: pathlib.Path) -> bool:
75
+ """
76
+ Checks whether the given path is a symbolic link that points to a regular file.
77
+
78
+ Args:
79
+ path (pathlib.Path): The path to evaluate.
80
+
81
+ Returns:
82
+ bool: True if the path is a symlink and its target is a regular file; False otherwise.
83
+ """
84
+ return path.is_symlink() and path.is_file()
85
+
86
+
87
+ def islinkd(path: pathlib.Path) -> bool:
88
+ """
89
+ Checks whether the given path is a symbolic link that points to a directory.
90
+
91
+ Args:
92
+ path (pathlib.Path): The path to evaluate.
93
+
94
+ Returns:
95
+ bool: True if the path is a symlink and its target is a directory; False otherwise.
96
+ """
97
+ return path.is_symlink() and path.is_dir()
98
+
99
+
100
+ def get_version() -> str:
101
+ """
102
+ Returns the current version of the tool using `setuptools_scm`.
103
+
104
+ Attempts to import the version from the auto-generated `_version.py` module.
105
+ Falls back to "unknown" if the version cannot be imported.
106
+
107
+ Returns:
108
+ str: The current version string, or "unknown" if unavailable.
109
+ """
110
+ try:
111
+ import makefiles._version as v
112
+
113
+ return v.version
114
+ except ImportError:
115
+ return "unknown"
116
+
117
+
118
+ def get_hinder(path: pathlib.Path) -> str | None:
119
+ """
120
+ Recursively identifies the nearest path component that would prevent file or directory creation.
121
+
122
+ This function checks if any part of the given path is a broken symlink or a non-directory
123
+ (excluding valid directory symlinks). It traverses up the path hierarchy until it either
124
+ finds such a hindrance or reaches the root.
125
+
126
+ Args:
127
+ path (pathlib.Path): The path to evaluate.
128
+
129
+ Returns:
130
+ str | None: The problematic path component as a string, or None if no hindrance is found.
131
+ """
132
+ if isbrokenlink(path) or not (isdir(path) or islinkd(path)):
133
+ return str(path)
134
+ if str(path) == "/":
135
+ return
136
+
137
+ return get_hinder(path.parent)
@@ -0,0 +1,81 @@
1
+ """
2
+ This module provides functions and classes for handling terminal input and output
3
+ in a consistent and controlled manner.
4
+
5
+ The built-in `input` and `print` functions can exhibit inconsistent behavior.
6
+ For example, the `input` function may write its prompt to `stderr` instead of
7
+ `stdout` under certain conditions. To avoid such inconsistencies, this module
8
+ uses the `sys` module to manage input and output manually.
9
+ """
10
+
11
+ import io
12
+ import sys
13
+
14
+
15
+ def _write_to_stream(text: str, *, stream: io.TextIOWrapper) -> None:
16
+ try:
17
+ stream.write(text)
18
+ except UnicodeEncodeError:
19
+ # Fallback to safe encoding
20
+ encoded = text.encode(stream.encoding or "utf-8", "backslashreplace")
21
+
22
+ if hasattr(stream, "buffer"):
23
+ stream.buffer.write(encoded)
24
+ else:
25
+ # Decode back to text if binary buffer is unavailable
26
+ fallback_text = encoded.decode(stream.encoding or "utf-8", "strict")
27
+ stream.write(fallback_text)
28
+
29
+
30
+ def print(text: str) -> None:
31
+ """
32
+ Writes the specified text to standard output (stdout) and flushes the stream.
33
+
34
+ Args:
35
+ text (str): The text string to be written to stdout.
36
+
37
+ Notes:
38
+ This function bypasses the built-in `print` to provide consistent
39
+ output behavior by directly writing to `sys.stdout`.
40
+
41
+ Side Effects:
42
+ Flushes the stdout buffer immediately after writing.
43
+ """
44
+ stream: io.TextIOWrapper = sys.stdout # type:ignore
45
+ _write_to_stream(text, stream=stream)
46
+ stream.flush()
47
+
48
+
49
+ def eprint(text: str) -> None:
50
+ """
51
+ Writes the specified text to standard error (stderr) and flushes the stream.
52
+
53
+ Args:
54
+ text (str): The text string to be written to stderr.
55
+
56
+ Notes:
57
+ This function directly writes to `sys.stderr`, providing consistent error
58
+ output behavior, bypassing the built-in `print`.
59
+
60
+ Side Effects:
61
+ Flushes the stderr buffer immediately after writing.
62
+ """
63
+ stream: io.TextIOWrapper = sys.stderr # type:ignore
64
+ _write_to_stream(text, stream=stream)
65
+ stream.flush()
66
+
67
+
68
+ def input() -> str:
69
+ """
70
+ Reads a line of input from standard input (stdin) and returns it as a string.
71
+
72
+ Returns:
73
+ str: The input string with the trailing newline character removed.
74
+
75
+ Notes:
76
+ This function reads directly from `sys.stdin` to avoid inconsistencies
77
+ sometimes observed with the built-in `input` function.
78
+ """
79
+ stream: io.TextIOWrapper = sys.stdin # type:ignore
80
+ input: str = stream.readline().rstrip("\n")
81
+ return input
@@ -0,0 +1,37 @@
1
+ import os
2
+ import pathlib
3
+
4
+ import makefiles.exceptions as exceptions
5
+ import makefiles.utils as utils
6
+
7
+
8
+ def listf(path: pathlib.Path) -> list[str]:
9
+ """
10
+ Recursively lists all non-hidden files within a directory, relative to the given path.
11
+
12
+ Args:
13
+ path (pathlib.Path): Path to a directory or a symbolic link to a directory.
14
+
15
+ Returns:
16
+ list[str]: A list of relative file paths (as strings) for all non-hidden files
17
+ within the directory tree rooted at `path`.
18
+
19
+ Raises:
20
+ makefiles.exceptions.InvalidPathError: If the provided path is not a directory or a symlink to a directory.
21
+ """
22
+ path = path.absolute()
23
+ if not (utils.isdir(path) or utils.islinkd(path)):
24
+ raise exceptions.InvalidPathError("given path is not a directory or link to directory")
25
+
26
+ result: list[str] = []
27
+
28
+ for root, dirs, files in os.walk(path, topdown=True):
29
+ # Exclude hidden directories
30
+ dirs[:] = filter(lambda d: not d.startswith("."), dirs)
31
+
32
+ for file in files:
33
+ if not file.startswith("."):
34
+ relative_path: str = os.path.relpath(os.path.join(root, file), path)
35
+ result.append(relative_path)
36
+
37
+ return result