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 +0 -0
- makefiles/_version.py +21 -0
- makefiles/cli_parser.py +80 -0
- makefiles/exceptions.py +82 -0
- makefiles/mkfile.py +121 -0
- makefiles/types.py +76 -0
- makefiles/utils/__init__.py +137 -0
- makefiles/utils/cli_io.py +81 -0
- makefiles/utils/dirwalker.py +37 -0
- makefiles/utils/fileutils.py +113 -0
- makefiles/utils/picker/__init__.py +7 -0
- makefiles/utils/picker/fzf.py +48 -0
- makefiles/utils/picker/manual.py +29 -0
- makefiles_cli-1.0.0.data/scripts/mkfile +8 -0
- makefiles_cli-1.0.0.dist-info/METADATA +69 -0
- makefiles_cli-1.0.0.dist-info/RECORD +19 -0
- makefiles_cli-1.0.0.dist-info/WHEEL +5 -0
- makefiles_cli-1.0.0.dist-info/licenses/LICENSE +674 -0
- makefiles_cli-1.0.0.dist-info/top_level.txt +1 -0
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)
|
makefiles/cli_parser.py
ADDED
|
@@ -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
|
makefiles/exceptions.py
ADDED
|
@@ -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
|