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 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
@@ -0,0 +1,7 @@
1
+ """Metadata for the package."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "Adrian Martinez Rodriguez"
5
+ __email__ = "adrianmrit@gmail.com"
6
+ __home__ = "https://github.com/adrianmrit/quickie"
7
+ __copyright__ = "Copyright (C) 2024 " + __author__
quickie/_version.py ADDED
@@ -0,0 +1,5 @@
1
+ """Read the version from installed package data."""
2
+
3
+ from _meta import __version__
4
+
5
+ __all__ = ["__version__"]
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)