quickie-runner 0.1.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.
- quickie/__init__.py +12 -0
- quickie/__main__.py +17 -0
- quickie/_meta.py +7 -0
- quickie/_version.py +5 -0
- quickie/argparser.py +73 -0
- quickie/cli.py +193 -0
- quickie/completion/__init__.py +0 -0
- quickie/completion/_internal.py +31 -0
- quickie/completion/base.py +78 -0
- quickie/completion/python.py +84 -0
- quickie/constants.py +18 -0
- quickie/context.py +33 -0
- quickie/errors.py +26 -0
- quickie/loader.py +54 -0
- quickie/namespace.py +109 -0
- quickie/tasks.py +432 -0
- quickie/utils/__init__.py +1 -0
- quickie/utils/imports.py +89 -0
- quickie_runner-0.1.0.dist-info/METADATA +153 -0
- quickie_runner-0.1.0.dist-info/RECORD +23 -0
- quickie_runner-0.1.0.dist-info/WHEEL +4 -0
- quickie_runner-0.1.0.dist-info/entry_points.txt +2 -0
- quickie_runner-0.1.0.dist-info/licenses/LICENSE +21 -0
quickie/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
3
|
+
"""A CLI tool for quick tasks."""
|
|
4
|
+
from ._meta import __author__, __copyright__, __email__, __home__, __version__
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"__author__",
|
|
8
|
+
"__copyright__",
|
|
9
|
+
"__email__",
|
|
10
|
+
"__home__",
|
|
11
|
+
"__version__",
|
|
12
|
+
]
|
quickie/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
3
|
+
"""Entry point for the application script."""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .cli import main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run_main():
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
argv = sys.argv[1:]
|
|
14
|
+
main(argv)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_run_main()
|
quickie/_meta.py
ADDED
quickie/_version.py
ADDED
quickie/argparser.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Custom argument parser for quickie."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
from argparse import ArgumentParser
|
|
5
|
+
|
|
6
|
+
import argcomplete
|
|
7
|
+
|
|
8
|
+
from quickie._meta import __version__ as version
|
|
9
|
+
from quickie.completion._internal import TaskCompleter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ArgumentsParser(ArgumentParser):
|
|
13
|
+
"""Custom argument parser for quickie."""
|
|
14
|
+
|
|
15
|
+
@typing.override
|
|
16
|
+
def __init__(self, main):
|
|
17
|
+
super().__init__(description="A CLI tool for quick tasks.")
|
|
18
|
+
module_or_global_group = self.add_mutually_exclusive_group()
|
|
19
|
+
self.add_argument("-V", "--version", action="version", version=version)
|
|
20
|
+
self.add_argument("-l", "--list", action="store_true", help="List tasks")
|
|
21
|
+
module_or_global_group.add_argument(
|
|
22
|
+
"-m", "--module", type=str, help="The module to load tasks from"
|
|
23
|
+
)
|
|
24
|
+
module_or_global_group.add_argument(
|
|
25
|
+
"-g",
|
|
26
|
+
"--global",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="Use global defined tasks",
|
|
29
|
+
dest="use_global",
|
|
30
|
+
)
|
|
31
|
+
module_or_global_group.add_argument(
|
|
32
|
+
"--autocomplete",
|
|
33
|
+
help="Suggest autocompletion for the shell",
|
|
34
|
+
dest="suggest_auto_completion",
|
|
35
|
+
choices=["bash", "zsh"],
|
|
36
|
+
).completer = argcomplete.ChoicesCompleter(["bash", "zsh"])
|
|
37
|
+
self.add_argument(
|
|
38
|
+
"task", nargs="?", help="The task to run"
|
|
39
|
+
).completer = TaskCompleter(main)
|
|
40
|
+
# This does not need completion as it is handled by the task completer
|
|
41
|
+
self.add_argument("args", nargs="*", help="The arguments to pass to the task")
|
|
42
|
+
|
|
43
|
+
@typing.override
|
|
44
|
+
def parse_known_args(self, args=None, namespace=None):
|
|
45
|
+
qck_args, task_args = self._partition_args(args)
|
|
46
|
+
namespace, argv = super().parse_known_args(qck_args, namespace)
|
|
47
|
+
|
|
48
|
+
if argv:
|
|
49
|
+
# Because the unknown arguments are not task arguments, we raise an error
|
|
50
|
+
msg = "unrecognized arguments: %s"
|
|
51
|
+
self.error(msg % " ".join(argv))
|
|
52
|
+
|
|
53
|
+
# namespace.task = task
|
|
54
|
+
namespace.args = task_args
|
|
55
|
+
return namespace, []
|
|
56
|
+
|
|
57
|
+
def _partition_args(self, args):
|
|
58
|
+
qck_args = []
|
|
59
|
+
task_args = []
|
|
60
|
+
args = iter(args)
|
|
61
|
+
while arg := next(args, None):
|
|
62
|
+
if arg in {"-m", "--module", "--autocomplete"}:
|
|
63
|
+
qck_args.append(arg)
|
|
64
|
+
qck_args.append(next(args))
|
|
65
|
+
elif arg.startswith("-"):
|
|
66
|
+
qck_args.append(arg)
|
|
67
|
+
else:
|
|
68
|
+
# Task found
|
|
69
|
+
qck_args.append(arg)
|
|
70
|
+
# The rest of the arguments are task arguments
|
|
71
|
+
task_args = list(args)
|
|
72
|
+
|
|
73
|
+
return qck_args, task_args
|
quickie/cli.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""The CLI entry of quickie."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import tomllib
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import argcomplete
|
|
10
|
+
from frozendict import frozendict
|
|
11
|
+
from rich import traceback
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.theme import Theme
|
|
14
|
+
|
|
15
|
+
import quickie
|
|
16
|
+
from quickie import constants
|
|
17
|
+
from quickie.argparser import ArgumentsParser
|
|
18
|
+
from quickie.context import Context
|
|
19
|
+
from quickie.errors import QuickieError, TaskNotFoundError
|
|
20
|
+
from quickie.loader import get_default_module_path, load_tasks_from_module
|
|
21
|
+
from quickie.namespace import RootNamespace
|
|
22
|
+
from quickie.utils import imports
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv=None, *, raise_error=False):
|
|
26
|
+
"""Run the CLI."""
|
|
27
|
+
traceback.install(suppress=[quickie])
|
|
28
|
+
main = Main(argv=argv)
|
|
29
|
+
try:
|
|
30
|
+
main()
|
|
31
|
+
except QuickieError as e:
|
|
32
|
+
if raise_error:
|
|
33
|
+
raise e
|
|
34
|
+
main.console.print(f"Error: [error]{e}[/error]", style="error")
|
|
35
|
+
sys.exit(e.exit_code)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Main:
|
|
39
|
+
"""Represents the CLI entry of quickie."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, argv=None): # noqa: PLR0913
|
|
42
|
+
"""Initialize the CLI."""
|
|
43
|
+
self.settings = self.load_settings()
|
|
44
|
+
if argv is None:
|
|
45
|
+
argv = sys.argv[1:]
|
|
46
|
+
self.argv = argv
|
|
47
|
+
|
|
48
|
+
self.console = Console(theme=Theme(self.settings["style"]))
|
|
49
|
+
|
|
50
|
+
self.global_context = Context(
|
|
51
|
+
program_name=os.path.basename(sys.argv[0]),
|
|
52
|
+
cwd=os.getcwd(),
|
|
53
|
+
env=frozendict(os.environ),
|
|
54
|
+
console=self.console,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
self.parser = ArgumentsParser(main=self)
|
|
58
|
+
|
|
59
|
+
def __call__(self):
|
|
60
|
+
"""Run the CLI."""
|
|
61
|
+
if os.environ.get("_ARGCOMPLETE"):
|
|
62
|
+
comp_line = os.environ["COMP_LINE"]
|
|
63
|
+
comp_point = int(os.environ["COMP_POINT"])
|
|
64
|
+
|
|
65
|
+
# Hack to parse the arguments
|
|
66
|
+
(_, _, _, comp_words, _) = argcomplete.lexers.split_line(
|
|
67
|
+
comp_line, comp_point
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# _ARGCOMPLETE is set by the shell script to tell us where comp_words
|
|
71
|
+
# should start, based on what we're completing.
|
|
72
|
+
# we ignore teh program name, hence no -1
|
|
73
|
+
start = int(os.environ["_ARGCOMPLETE"])
|
|
74
|
+
comp_words = comp_words[start:]
|
|
75
|
+
namespace = self.parser.parse_args(comp_words)
|
|
76
|
+
if namespace.task:
|
|
77
|
+
self.load_tasks_from_namespace(namespace)
|
|
78
|
+
task = self.get_task(namespace.task)
|
|
79
|
+
os.environ["_ARGCOMPLETE"] = str(comp_words.index(namespace.task))
|
|
80
|
+
argcomplete.autocomplete(task.parser)
|
|
81
|
+
else:
|
|
82
|
+
argcomplete.autocomplete(self.parser)
|
|
83
|
+
|
|
84
|
+
namespace = self.parser.parse_args(self.argv)
|
|
85
|
+
self.load_tasks_from_namespace(namespace)
|
|
86
|
+
if namespace.suggest_auto_completion:
|
|
87
|
+
if namespace.suggest_auto_completion == "bash":
|
|
88
|
+
self.suggest_autocompletion_bash()
|
|
89
|
+
elif namespace.suggest_auto_completion == "zsh":
|
|
90
|
+
self.suggest_autocompletion_zsh()
|
|
91
|
+
elif namespace.list:
|
|
92
|
+
self.list_tasks()
|
|
93
|
+
elif namespace.task is not None:
|
|
94
|
+
self.run_task(task_name=namespace.task, args=namespace.args)
|
|
95
|
+
else:
|
|
96
|
+
self.console.print(self.get_usage())
|
|
97
|
+
self.parser.exit()
|
|
98
|
+
|
|
99
|
+
@cached_property
|
|
100
|
+
def tasks_namespace(self):
|
|
101
|
+
"""Get the namespace."""
|
|
102
|
+
return RootNamespace()
|
|
103
|
+
|
|
104
|
+
def suggest_autocompletion_bash(self):
|
|
105
|
+
"""Suggest autocompletion for bash."""
|
|
106
|
+
self.console.print("Add the following to ~/.bashrc or ~/.bash_profile:")
|
|
107
|
+
self.console.print(
|
|
108
|
+
'eval "$(register-python-argcomplete qck)"',
|
|
109
|
+
style="bold green",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def suggest_autocompletion_zsh(self):
|
|
113
|
+
"""Suggest autocompletion for zsh."""
|
|
114
|
+
self.console.print("Add the following to ~/.zshrc:")
|
|
115
|
+
self.console.print(
|
|
116
|
+
'eval "$(register-python-argcomplete qck)"',
|
|
117
|
+
style="bold green",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def load_tasks_from_namespace(self, namespace):
|
|
121
|
+
"""Load tasks from the namespace."""
|
|
122
|
+
if namespace.module is not None:
|
|
123
|
+
tasks_module_path = Path(namespace.module)
|
|
124
|
+
elif namespace.use_global:
|
|
125
|
+
tasks_module_path = constants.HOME_PATH
|
|
126
|
+
else:
|
|
127
|
+
tasks_module_path = get_default_module_path()
|
|
128
|
+
self.load_tasks(path=tasks_module_path)
|
|
129
|
+
|
|
130
|
+
def load_settings(self):
|
|
131
|
+
"""Load the console theme."""
|
|
132
|
+
defaults = frozendict({"style": constants.DEFAULT_CONSOLE_STYLE})
|
|
133
|
+
if constants.SETTINGS_PATH.exists():
|
|
134
|
+
with constants.SETTINGS_PATH.open("r") as f:
|
|
135
|
+
user_settings = tomllib.load(f)
|
|
136
|
+
user_settings["style"] = frozendict(
|
|
137
|
+
defaults["style"] | user_settings.get("style", {})
|
|
138
|
+
)
|
|
139
|
+
return frozendict(user_settings)
|
|
140
|
+
return defaults
|
|
141
|
+
|
|
142
|
+
def list_tasks(self):
|
|
143
|
+
"""List the available tasks."""
|
|
144
|
+
import rich.text
|
|
145
|
+
import rich.tree
|
|
146
|
+
|
|
147
|
+
tree = rich.tree.Tree(
|
|
148
|
+
"Available tasks:", style="bold green", guide_style="info"
|
|
149
|
+
)
|
|
150
|
+
node_by_namespace = {}
|
|
151
|
+
for task_path, task in sorted(self.tasks_namespace.items(), key=lambda x: x[0]):
|
|
152
|
+
if ":" in task_path:
|
|
153
|
+
namespace, task_name = task_path.rsplit(":", 1)
|
|
154
|
+
else:
|
|
155
|
+
task_name = task_path
|
|
156
|
+
namespace = ""
|
|
157
|
+
|
|
158
|
+
task_info = rich.text.Text(task_name, style="info")
|
|
159
|
+
if task._meta.short_help:
|
|
160
|
+
task_info.append(f"\n {task._meta.short_help}", style="green")
|
|
161
|
+
if namespace:
|
|
162
|
+
if namespace not in node_by_namespace:
|
|
163
|
+
node_by_namespace[namespace] = tree.add(
|
|
164
|
+
namespace, style="bold yellow", guide_style="yellow"
|
|
165
|
+
)
|
|
166
|
+
node = node_by_namespace[namespace]
|
|
167
|
+
else:
|
|
168
|
+
node = tree
|
|
169
|
+
node.add(task_info)
|
|
170
|
+
self.console.print(tree)
|
|
171
|
+
|
|
172
|
+
def load_tasks(self, *, path: Path):
|
|
173
|
+
"""Load tasks from the tasks module."""
|
|
174
|
+
root = Path.cwd()
|
|
175
|
+
module = imports.import_from_path(root / path)
|
|
176
|
+
load_tasks_from_module(module, namespace=self.tasks_namespace)
|
|
177
|
+
|
|
178
|
+
def get_usage(self):
|
|
179
|
+
"""Get the usage message."""
|
|
180
|
+
return self.parser.format_usage()
|
|
181
|
+
|
|
182
|
+
def get_task(self, task_name):
|
|
183
|
+
"""Get a task by name."""
|
|
184
|
+
try:
|
|
185
|
+
task_class = self.tasks_namespace.get_task_class(task_name)
|
|
186
|
+
return task_class(name=task_name, context=self.global_context)
|
|
187
|
+
except KeyError:
|
|
188
|
+
raise TaskNotFoundError(task_name)
|
|
189
|
+
|
|
190
|
+
def run_task(self, task_name, args):
|
|
191
|
+
"""Run a task."""
|
|
192
|
+
task = self.get_task(task_name)
|
|
193
|
+
return task(args)
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Arg completers for quickie CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
from quickie.completion.base import BaseCompleter
|
|
8
|
+
from quickie.errors import QuickieError
|
|
9
|
+
|
|
10
|
+
if typing.TYPE_CHECKING:
|
|
11
|
+
from quickie.cli import Main as TMain # pragma: no cover
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskCompleter(BaseCompleter):
|
|
15
|
+
"""For auto-completing task names. Used internally by the CLI."""
|
|
16
|
+
|
|
17
|
+
@typing.override
|
|
18
|
+
def __init__(self, main: TMain):
|
|
19
|
+
self.main = main
|
|
20
|
+
|
|
21
|
+
@typing.override
|
|
22
|
+
def complete(self, *, prefix, parsed_args, **_):
|
|
23
|
+
try:
|
|
24
|
+
self.main.load_tasks_from_namespace(parsed_args)
|
|
25
|
+
return {
|
|
26
|
+
key: task._meta.short_help or ""
|
|
27
|
+
for key, task in self.main.tasks_namespace.items()
|
|
28
|
+
if key.startswith(prefix)
|
|
29
|
+
}
|
|
30
|
+
except QuickieError:
|
|
31
|
+
pass
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Base class for auto-completing python modules."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import traceback
|
|
6
|
+
import typing
|
|
7
|
+
|
|
8
|
+
import argcomplete
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseCompleter(argcomplete.completers.BaseCompleter):
|
|
12
|
+
"""For auto-completing python modules."""
|
|
13
|
+
|
|
14
|
+
def complete(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
prefix: str,
|
|
18
|
+
action: argparse.Action,
|
|
19
|
+
parser: argparse.ArgumentParser,
|
|
20
|
+
parsed_args: argparse.Namespace,
|
|
21
|
+
) -> list[str] | dict[str, str]:
|
|
22
|
+
"""Complete the prefix."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@typing.override
|
|
26
|
+
def __call__(
|
|
27
|
+
self,
|
|
28
|
+
prefix: str,
|
|
29
|
+
action: argparse.Action,
|
|
30
|
+
parser: argparse.ArgumentParser,
|
|
31
|
+
parsed_args: argparse.Namespace,
|
|
32
|
+
):
|
|
33
|
+
"""Call the completer."""
|
|
34
|
+
try:
|
|
35
|
+
return self.complete(
|
|
36
|
+
prefix=prefix, action=action, parser=parser, parsed_args=parsed_args
|
|
37
|
+
)
|
|
38
|
+
except Exception:
|
|
39
|
+
# Include stack trace in the warning
|
|
40
|
+
argcomplete.warn(
|
|
41
|
+
f"Autocompletion by {self.__class__.__name__} failed with error:",
|
|
42
|
+
traceback.format_exc(),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PathCompleter(BaseCompleter):
|
|
47
|
+
"""For auto-completing file paths."""
|
|
48
|
+
|
|
49
|
+
def get_pre_filtered_paths(self, target_dir: str) -> typing.Iterator[str]:
|
|
50
|
+
"""Get path names in the target directory."""
|
|
51
|
+
try:
|
|
52
|
+
return os.listdir(target_dir or ".")
|
|
53
|
+
except Exception:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
def get_paths(self, prefix: str) -> typing.Generator[str, None, None]:
|
|
57
|
+
"""Get path names that match the prefix."""
|
|
58
|
+
target_dir = os.path.dirname(prefix)
|
|
59
|
+
names = self.get_pre_filtered_paths(target_dir)
|
|
60
|
+
incomplete_part = os.path.basename(prefix)
|
|
61
|
+
# Iterate on target_dir entries and filter on given predicate
|
|
62
|
+
for name in names:
|
|
63
|
+
if not name.startswith(incomplete_part):
|
|
64
|
+
continue
|
|
65
|
+
candidate = os.path.join(target_dir, name)
|
|
66
|
+
yield candidate + "/" if os.path.isdir(candidate) else candidate
|
|
67
|
+
|
|
68
|
+
@typing.override
|
|
69
|
+
def complete(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
prefix: str,
|
|
73
|
+
action: argparse.Action,
|
|
74
|
+
parser: argparse.ArgumentParser,
|
|
75
|
+
parsed_args: argparse.Namespace,
|
|
76
|
+
):
|
|
77
|
+
"""Complete the prefix."""
|
|
78
|
+
return list(self.get_paths(prefix))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Python completers for quickie."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
from quickie.completion.base import PathCompleter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PytestCompleter(PathCompleter):
|
|
10
|
+
"""For auto-completing pytest arguments."""
|
|
11
|
+
|
|
12
|
+
@typing.override
|
|
13
|
+
def complete(self, prefix, **kwargs):
|
|
14
|
+
path = prefix.split("::")
|
|
15
|
+
path = [part for part in path if part]
|
|
16
|
+
node_names = []
|
|
17
|
+
partial_name = None
|
|
18
|
+
|
|
19
|
+
if len(path) == 1:
|
|
20
|
+
file_path = path[0]
|
|
21
|
+
elif path:
|
|
22
|
+
file_path = path[0]
|
|
23
|
+
node_names = path[1:]
|
|
24
|
+
if not prefix.endswith("::"):
|
|
25
|
+
partial_name = node_names.pop()
|
|
26
|
+
else:
|
|
27
|
+
file_path = prefix
|
|
28
|
+
|
|
29
|
+
if file_path.endswith(".py"):
|
|
30
|
+
pre_resolved_path = file_path
|
|
31
|
+
if node_names:
|
|
32
|
+
pre_resolved_path += "::" + "::".join(node_names)
|
|
33
|
+
|
|
34
|
+
return [
|
|
35
|
+
f"{pre_resolved_path}::{node_name}"
|
|
36
|
+
for node_name in self.get_python_paths(
|
|
37
|
+
file_path, node_names, partial_name
|
|
38
|
+
)
|
|
39
|
+
]
|
|
40
|
+
else:
|
|
41
|
+
return super().complete(prefix=file_path, **kwargs)
|
|
42
|
+
|
|
43
|
+
@typing.override
|
|
44
|
+
def get_paths(self, prefix: str) -> typing.Generator[str, None, None]:
|
|
45
|
+
paths = super().get_paths(prefix)
|
|
46
|
+
for path in paths:
|
|
47
|
+
yield path
|
|
48
|
+
if path.endswith(".py"):
|
|
49
|
+
yield f"{path}::"
|
|
50
|
+
|
|
51
|
+
def read_python_file(self, file_path: str) -> str:
|
|
52
|
+
"""Read the python file to a string."""
|
|
53
|
+
with open(file_path) as file:
|
|
54
|
+
return file.read()
|
|
55
|
+
|
|
56
|
+
def get_python_paths(
|
|
57
|
+
self, file_path: str, python_path: list[str], partial_name: str | None
|
|
58
|
+
) -> typing.Generator[str, None, None]:
|
|
59
|
+
"""Complete the module."""
|
|
60
|
+
try:
|
|
61
|
+
tree = ast.parse(self.read_python_file(file_path), file_path)
|
|
62
|
+
except SyntaxError:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# resolve the tree up to the last node
|
|
66
|
+
for item in python_path:
|
|
67
|
+
for node in ast.walk(tree):
|
|
68
|
+
if isinstance(node, ast.ClassDef) and node.name == item:
|
|
69
|
+
tree = node
|
|
70
|
+
break
|
|
71
|
+
else:
|
|
72
|
+
# Either no class with the name was found, or the node was not a class
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Return immediate children of the class or module
|
|
76
|
+
for node in ast.iter_child_nodes(tree):
|
|
77
|
+
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
|
|
78
|
+
if partial_name and not node.name.startswith(partial_name):
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
yield node.name
|
|
82
|
+
# Because the class might contain inner tests
|
|
83
|
+
if isinstance(node, (ast.ClassDef)):
|
|
84
|
+
yield node.name + "::"
|
quickie/constants.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Settings for quickie."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from frozendict import frozendict
|
|
6
|
+
|
|
7
|
+
HOME_PATH = Path.home() / "Quickie"
|
|
8
|
+
SETTINGS_PATH = HOME_PATH / "settings.toml"
|
|
9
|
+
TASKS_PATH = Path("__quickie")
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONSOLE_STYLE = frozendict(
|
|
12
|
+
{
|
|
13
|
+
"info": "cyan",
|
|
14
|
+
"warning": "yellow",
|
|
15
|
+
"error": "bold red",
|
|
16
|
+
"success": "green",
|
|
17
|
+
}
|
|
18
|
+
)
|
quickie/context.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Task context."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from frozendict import frozendict
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Context:
|
|
10
|
+
"""The context for a task."""
|
|
11
|
+
|
|
12
|
+
def __init__( # noqa: PLR0913
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
program_name,
|
|
16
|
+
cwd: str,
|
|
17
|
+
env: typing.Mapping,
|
|
18
|
+
console: Console,
|
|
19
|
+
):
|
|
20
|
+
"""Initialize the context."""
|
|
21
|
+
self.program_name = program_name
|
|
22
|
+
self.cwd = cwd
|
|
23
|
+
self.env = frozendict(env)
|
|
24
|
+
self.console = console
|
|
25
|
+
|
|
26
|
+
def copy(self):
|
|
27
|
+
"""Copy the context."""
|
|
28
|
+
return Context(
|
|
29
|
+
program_name=self.program_name,
|
|
30
|
+
cwd=self.cwd,
|
|
31
|
+
env=self.env,
|
|
32
|
+
console=self.console,
|
|
33
|
+
)
|
quickie/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Errors for quickie."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class QuickieError(Exception):
|
|
5
|
+
"""Base class for quickie errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message, *, exit_code):
|
|
8
|
+
"""Initialize the error."""
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.exit_code = exit_code
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskNotFoundError(QuickieError):
|
|
14
|
+
"""Raised when a task is not found."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, task_name):
|
|
17
|
+
"""Initialize the error."""
|
|
18
|
+
super().__init__(f"Task '{task_name}' not found", exit_code=1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TasksModuleNotFoundError(QuickieError):
|
|
22
|
+
"""Raised when a module is not found."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, module_name):
|
|
25
|
+
"""Initialize the error."""
|
|
26
|
+
super().__init__(f"Tasks module {module_name} not found", exit_code=2)
|
quickie/loader.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Task loader."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from quickie import constants
|
|
6
|
+
from quickie.errors import TasksModuleNotFoundError
|
|
7
|
+
from quickie.namespace import Namespace
|
|
8
|
+
from quickie.tasks import Task
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_default_module_path():
|
|
12
|
+
"""Get the default module path."""
|
|
13
|
+
current = Path.cwd()
|
|
14
|
+
while True:
|
|
15
|
+
path = current / constants.TASKS_PATH
|
|
16
|
+
if (path).exists():
|
|
17
|
+
return path
|
|
18
|
+
if current == current.parent:
|
|
19
|
+
break
|
|
20
|
+
current = current.parent
|
|
21
|
+
raise TasksModuleNotFoundError(constants.TASKS_PATH)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_tasks_from_module(module, namespace):
|
|
25
|
+
"""Load tasks from a module."""
|
|
26
|
+
modules = [(module, namespace)]
|
|
27
|
+
handled_modules = set()
|
|
28
|
+
while modules:
|
|
29
|
+
module, namespace = modules.pop()
|
|
30
|
+
# If the module has a namespace, we handle them first. This way
|
|
31
|
+
# if their namespace name is empty, and there is a task with the same
|
|
32
|
+
# name in both the parent module and the child module, the parent
|
|
33
|
+
# module task will be registered last and will be the one that is
|
|
34
|
+
# returned when getting the task by name.
|
|
35
|
+
if hasattr(module, "QCK_NAMESPACES") and module not in handled_modules:
|
|
36
|
+
modules.append((module, namespace))
|
|
37
|
+
handled_modules.add(module)
|
|
38
|
+
for name, sub_module in module.QCK_NAMESPACES.items():
|
|
39
|
+
if name:
|
|
40
|
+
sub_namespace = Namespace(name=name, parent=namespace)
|
|
41
|
+
else:
|
|
42
|
+
sub_namespace = namespace
|
|
43
|
+
modules.append((sub_module, sub_namespace))
|
|
44
|
+
else:
|
|
45
|
+
for name, obj in module.__dict__.items():
|
|
46
|
+
if isinstance(obj, type) and issubclass(obj, Task):
|
|
47
|
+
meta = getattr(obj, "_meta")
|
|
48
|
+
if meta.abstract:
|
|
49
|
+
continue
|
|
50
|
+
aliases = meta.alias
|
|
51
|
+
if isinstance(aliases, str):
|
|
52
|
+
aliases = [aliases]
|
|
53
|
+
for alias in aliases:
|
|
54
|
+
namespace.register(obj, name=alias)
|