diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..ae2b5100 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +source "$(m . /dev/makes)/template" diff --git a/README.md b/README.md index 8737d314..582d3d9e 100644 --- a/README.md +++ b/README.md @@ -576,15 +576,6 @@ you need to use the **same version** of the framework and the CLI. For example: `21.11`. -## Command-line completion - -We currently have [Command-line Completion][CLI_COMPLETION] scripts -for the following shells: - -- [Bash][BASH]: - Add this: `source <(m-comp-bash)` - to the end of your `~/.bashrc` - # Configuring CI/CD ## Providers comparison diff --git a/default.nix b/default.nix index 9d57ea33..0ca4c46d 100644 --- a/default.nix +++ b/default.nix @@ -2,55 +2,41 @@ , }: -with import ./src/args/agnostic.nix { inherit system; }; - let - bin = makeScript { - aliases = [ - "m-v21.11" - "makes" - "makes-v21.11" - ]; - replace = { - __argMakesSrc__ = ./.; - __argNixStable__ = __nixpkgs__.nixStable; - __argNixUnstable__ = __nixpkgs__.nixUnstable; - }; - entrypoint = '' - __MAKES_REGISTRY__=__argMakesSrc__/src/cli/main/registry.json \ - __MAKES_SRC__=__argMakesSrc__ \ - __NIX_STABLE__=__argNixStable__ \ - __NIX_UNSTABLE__=__argNixUnstable__ \ - python -u __argMakesSrc__/src/cli/main/__main__.py "$@" - ''; - searchPaths = { - bin = [ - __nixpkgs__.cachix - __nixpkgs__.git - __nixpkgs__.gnutar - __nixpkgs__.gzip - __nixpkgs__.nixStable - __nixpkgs__.python38 - ]; - pythonPackage38 = [ - __nixpkgs__.python38Packages.pygments - __nixpkgs__.python38Packages.rich - ]; - }; - name = "m"; + args = import ./src/args/agnostic.nix { inherit system; }; + + inherit (args) __nixpkgs__; + inherit (args) makeScript; +in +makeScript { + aliases = [ + "m-v21.11" + "makes" + "makes-v21.11" + ]; + replace = { + __argMakesSrc__ = ./.; + __argNixStable__ = __nixpkgs__.nixStable; + __argNixUnstable__ = __nixpkgs__.nixUnstable; }; - compBash = makeScript { - replace = { - __argCompBash__ = ./src/comp/bash.sh; - }; - entrypoint = "cat __argCompBash__"; - name = "m-comp-bash"; + entrypoint = '' + __MAKES_REGISTRY__=__argMakesSrc__/src/cli/main/registry.json \ + __MAKES_SRC__=__argMakesSrc__ \ + __NIX_STABLE__=__argNixStable__ \ + __NIX_UNSTABLE__=__argNixUnstable__ \ + python -u __argMakesSrc__/src/cli/main/__main__.py "$@" + ''; + searchPaths = { + bin = [ + __nixpkgs__.cachix + __nixpkgs__.git + __nixpkgs__.gnutar + __nixpkgs__.gzip + __nixpkgs__.nixStable + ]; + source = [ + (import ./makes/cli/pypi/main.nix args) + ]; }; -in -__nixpkgs__.symlinkJoin { name = "m"; - paths = [ - bin - compBash - ]; } diff --git a/makes.nix b/makes.nix index 150d3568..9d3d6eb4 100644 --- a/makes.nix +++ b/makes.nix @@ -35,6 +35,9 @@ example = { bin = [ inputs.nixpkgs.hello ]; }; + makes = { + source = [ outputs."/cli/pypi" ]; + }; }; envVars = { example = { @@ -109,9 +112,7 @@ lintPython = let searchPaths = { - pythonPackage38 = [ - __nixpkgs__.python38Packages.rich - ]; + source = [ outputs."/cli/pypi" ]; }; in { diff --git a/makes/cli/pypi/main.nix b/makes/cli/pypi/main.nix new file mode 100644 index 00000000..2a90a3bd --- /dev/null +++ b/makes/cli/pypi/main.nix @@ -0,0 +1,7 @@ +{ makePythonPypiEnvironment +, ... +}: +makePythonPypiEnvironment { + name = "cli-pypi"; + sourcesYaml = ./pypi-sources.yaml; +} diff --git a/makes/cli/pypi/pypi-deps.yaml b/makes/cli/pypi/pypi-deps.yaml new file mode 100644 index 00000000..e93413f7 --- /dev/null +++ b/makes/cli/pypi/pypi-deps.yaml @@ -0,0 +1,2 @@ +rich: "*" +textual: "*" diff --git a/makes/cli/pypi/pypi-sources.yaml b/makes/cli/pypi/pypi-sources.yaml new file mode 100644 index 00000000..37b44587 --- /dev/null +++ b/makes/cli/pypi/pypi-sources.yaml @@ -0,0 +1,23 @@ +closure: + colorama: 0.4.4 + commonmark: 0.9.1 + pygments: 2.10.0 + rich: 10.12.0 + textual: 0.1.12 +links: + - name: colorama-0.4.4-py2.py3-none-any.whl + sha256: 1qn3bnyd7gdwkyk8nvlhiy3c6zbwjd49fjxj0gp8xxi9faiysiwz + url: https://files.pythonhosted.org/packages/44/98/5b86278fbbf250d239ae0ecb724f8572af1c91f4a11edf4d36a206189440/colorama-0.4.4-py2.py3-none-any.whl + - name: commonmark-0.9.1-py2.py3-none-any.whl + sha256: 1nbgsvb73ad93cjzjdggkpp4zizvxay3q6ms23j3vy4h4p4khbys + url: https://files.pythonhosted.org/packages/b1/92/dfd892312d822f36c55366118b95d914e5f16de11044a27cf10a7d71bbbf/commonmark-0.9.1-py2.py3-none-any.whl + - name: Pygments-2.10.0-py3-none-any.whl + sha256: 1003l9kipymrk4n3nj8yxf5z1jr40q69gqmkqjrr5x3qmzk7zrmq + url: https://files.pythonhosted.org/packages/78/c8/8d9be2f72d8f465461f22b5f199c04f7ada933add4dae6e2468133c17471/Pygments-2.10.0-py3-none-any.whl + - name: rich-10.12.0-py3-none-any.whl + sha256: 0yzkqdmvbjsv0dzmikz6bpsvalrys63xblkvdbayygfds446h3f3 + url: https://files.pythonhosted.org/packages/de/80/f8d5689d21e98e2d4397975969fb329b8e08aa31a15046fd32ef24279cc5/rich-10.12.0-py3-none-any.whl + - name: textual-0.1.12-py3-none-any.whl + sha256: 0xr4cn53pv5skzk0h4vqp97y3caisx5wnvcq4l534lyg0v6fk59g + url: https://files.pythonhosted.org/packages/12/95/330c20294045571906e2566fafbad2779f69e9605a05f70862cea7f81cec/textual-0.1.12-py3-none-any.whl +python: "3.9" diff --git a/src/cli/main/__main__.py b/src/cli/main/__main__.py index 260db324..6b4325b7 100644 --- a/src/cli/main/__main__.py +++ b/src/cli/main/__main__.py @@ -14,6 +14,7 @@ remove, ) from os.path import ( + commonprefix, exists, getctime, join, @@ -24,12 +25,23 @@ ) import random import re +import rich.align import rich.console +import rich.markup import rich.panel +import rich.table +import rich.text +import shlex import shutil import subprocess # nosec import sys import tempfile +import textual.app +import textual.events +import textual.keys +import textual.reactive +import textual.widget +import textual.widgets import textwrap from time import ( time, @@ -49,6 +61,7 @@ from uuid import ( uuid4 as uuid, ) +import warnings CWD: str = getcwd() CON: rich.console.Console = rich.console.Console( @@ -56,6 +69,7 @@ file=io.TextIOWrapper(sys.stderr.buffer, write_through=True), ) MAKES_DIR: str = join(environ["HOME_IMPURE"], ".makes") +makedirs(join(MAKES_DIR, "cache"), exist_ok=True) SOURCES_CACHE: str = join(MAKES_DIR, "cache", "sources") ON_EXIT: List[Callable[[], None]] = [] VERSION: str = "21.11" @@ -182,7 +196,9 @@ def _if(condition: Any, *value: Any) -> List[Any]: def _clone_src(src: str) -> str: # pylint: disable=consider-using-with - head = tempfile.TemporaryDirectory(prefix="makes-").name + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + head = tempfile.TemporaryDirectory(prefix="makes-").name ON_EXIT.append(partial(shutil.rmtree, head, ignore_errors=True)) if abspath(src) == CWD: # `m .` ? @@ -201,7 +217,7 @@ def _clone_src(src: str) -> str: CON.print("It has an unrecognized format", justify="center") CON.print() CON.print("Please see the correct usage below", justify="center") - _help_and_exit() + _help_and_exit_base() _clone_src_git_init(head) remote = _clone_src_cache_get(src, cache_key, remote) @@ -214,7 +230,7 @@ def _clone_src(src: str) -> str: def _clone_src_git_init(head: str) -> None: cmd = ["git", "init", "--initial-branch=____", "--shared=false", head] - out, _, _ = _run(cmd, stderr=None, stdout=None) + out, _, _ = _run(cmd, stderr=None, stdout=sys.stderr.fileno()) if out != 0: raise SystemExit(out) @@ -222,21 +238,21 @@ def _clone_src_git_init(head: str) -> None: def _clone_src_git_fetch(head: str, remote: str, rev: str) -> None: depth = _if(GIT_DEPTH >= 1, f"--depth={GIT_DEPTH}") cmd = ["git", "-C", head, "fetch", *depth, remote, f"{rev}:{rev}"] - out, _, _ = _run(cmd, stderr=None, stdout=None) + out, _, _ = _run(cmd, stderr=None, stdout=sys.stderr.fileno()) if out != 0: raise SystemExit(out) def _clone_src_git_checkout(head: str, rev: str) -> None: cmd = ["git", "-C", head, "checkout", rev] - out, _, _ = _run(cmd, stderr=None, stdout=None) + out, _, _ = _run(cmd, stderr=None, stdout=sys.stderr.fileno()) if out != 0: raise SystemExit(out) def _clone_src_git_worktree_add(remote: str, head: str) -> None: cmd = ["git", "-C", remote, "worktree", "add", head, "HEAD"] - out, _, _ = _run(cmd, stderr=None, stdout=None) + out, _, _ = _run(cmd, stderr=None, stdout=sys.stderr.fileno()) if out != 0: raise SystemExit(out) CON.out(head) @@ -471,55 +487,55 @@ def _run( # pylint: disable=too-many-arguments return process.returncode, out, err -def _help_and_exit( - src: Optional[str] = None, - attrs: Optional[List[str]] = None, -) -> None: +def _help_and_exit_base() -> None: CON.out() CON.rule("Usage") CON.out() - if src: - text = f"$ m {src} OUTPUT [ARGS...]" - else: - text = "$ m SOURCE OUTPUT [ARGS...]" - + text = "$ m SOURCE" CON.print(rich.panel.Panel.fit(text), justify="center") CON.out() - if not src: - text = """ - Can be: + text = """ + Can be: - A git repository in the current working directory: - $ m . + A git repository in the current working directory: + $ m . - A git repository and revision: - $ m local:/path/to/repo@rev + A git repository and revision: + $ m local:/path/to/repo@rev - A GitHub repository and revision: - $ m github:owner/repo@rev + A GitHub repository and revision: + $ m github:owner/repo@rev - A GitLab repository and revision: - $ m gitlab:owner/repo@rev + A GitLab repository and revision: + $ m gitlab:owner/repo@rev - Note: A revision is either a branch, full commit or tag - """ - CON.print(rich.panel.Panel(textwrap.dedent(text), title="SOURCE")) - CON.out() + Note: A revision is either a branch, full commit or tag + """ + CON.print(rich.panel.Panel(textwrap.dedent(text), title="SOURCE")) + CON.out() - if attrs is None: - text = "The available outputs will be listed when you provide a source" - CON.print(rich.panel.Panel(text, title="OUTPUT")) - else: - text = "Can be:\n\n" - for attr in attrs: - if attr not in { - "__all__", - "/secretsForAwsFromEnv/__default__", - }: - text += f" {attr}\n" - CON.print(rich.panel.Panel(text, title="OUTPUT")) + raise SystemExit(1) + + +def _help_and_exit_with_src_no_tty(src: str, attrs: List[str]) -> None: + CON.out() + CON.rule("Usage") + CON.out() + + text = f"$ m {src} OUTPUT [ARGS...]" + CON.print(rich.panel.Panel.fit(text), justify="center") + CON.out() + + text = "Can be:\n\n" + for attr in attrs: + if attr not in { + "__all__", + "/secretsForAwsFromEnv/__default__", + }: + text += f" {attr}\n" + CON.print(rich.panel.Panel(text, title="OUTPUT")) CON.out() text = "Zero or more arguments to pass to the output (if supported)." @@ -528,6 +544,198 @@ def _help_and_exit( raise SystemExit(1) +class TuiHeader(textual.widget.Widget): + def render(self) -> rich.text.Text: + text = ":unicorn_face: Makes" + return rich.text.Text.from_markup(text, justify="center") + + +class TuiUsage(textual.widget.Widget): + def __init__(self, *args: Any, src: str, **kwargs: Any) -> None: + self.src = src + super().__init__(*args, **kwargs) + + def render(self) -> rich.align.Align: + text = f"$ m {self.src} OUTPUT [ARGS...]" + panel = rich.panel.Panel.fit(text, title="Usage") + return rich.align.Align(panel, align="center") + + +class TuiCommand(textual.widget.Widget): + input = textual.reactive.Reactive("") + + def __init__(self, *args: Any, src: str, **kwargs: Any) -> None: + self.src = src + super().__init__(*args, **kwargs) + + def render(self) -> rich.align.Align: + panel = rich.panel.Panel.fit( + renderable=f"$ m {self.src} {self.input}", + title="Please type the command you want to execute:", + ) + return rich.align.Align(panel, align="center") + + +class TuiOutputs(textual.widget.Widget): + outputs = textual.reactive.Reactive([]) + + def render(self) -> rich.text.Text: + if self.outputs: + longest = max(map(len, self.outputs)) + text = "\n".join(output.ljust(longest) for output in self.outputs) + else: + text = "(none)" + text = rich.text.Text(text) + return rich.align.Align(text, align="center") + + +class TuiOutputsTitle(textual.widget.Widget): + output = textual.reactive.Reactive("") + + def render(self) -> rich.text.Text: + text = f"Outputs starting with: {self.output}" + return rich.text.Text(text, justify="center") + + +class TextUserInterface(textual.app.App): + # pylint: disable=too-many-instance-attributes + def __init__( + self, + *args: Any, + src: str, + attrs: List[str], + initial_input: str, + **kwargs: Any, + ) -> None: + self.attrs = attrs + self.src = src + + self.command = TuiCommand(src=src) + self.header = TuiHeader() + self.outputs = TuiOutputs() + self.outputs_scroll = None + self.outputs_title = TuiOutputsTitle() + self.usage = TuiUsage(src=src) + + self.args: List[str] + self.input = initial_input + self.output: str + self.output_matches: List[str] + self.propagate_data() + if self.output not in self.attrs: + self.input = "/" + self.propagate_data() + + super().__init__(*args, **kwargs) + + async def on_key(self, event: textual.events.Key) -> None: + if event.key in { + textual.keys.Keys.ControlH, + textual.keys.Keys.Backspace, + }: + if len(self.input) >= 2: + self.input = self.input[:-1] + self.propagate_data() + elif event.key == textual.keys.Keys.Down: + self.outputs_scroll.scroll_up() # type: ignore + elif event.key == textual.keys.Keys.Up: + self.outputs_scroll.scroll_down() # type: ignore + elif event.key in { + textual.keys.Keys.ControlI, + textual.keys.Keys.Tab, + }: + self.propagate_data(autocomplete=True) + elif event.key == textual.keys.Keys.Enter: + if self.validate(): + sys.argv = [sys.argv[0], self.src, self.output, *self.args] + await self.action_quit() + else: + self.input += event.key + self.propagate_data(autocomplete=True) + + def propagate_data(self, autocomplete: bool = False) -> None: + tokens = self.input.split(" ") + self.output, *self.args = tokens + self.output_matches = [ + attr + for attr in self.attrs + if attr.lower().startswith(self.output.lower()) + ] + if autocomplete and self.output_matches: + self.output = commonprefix(self.output_matches) + tokens = [self.output, *self.args] + + self.input = " ".join(tokens) + self.command.input = self.input + self.outputs_title.output = self.output + self.outputs.outputs = self.output_matches + self.validate() + + def validate(self) -> bool: + valid: bool = True + + try: + shlex.split(self.input) + except ValueError: + valid = valid and False + else: + valid = valid and True + + valid = valid and (self.output in self.attrs) + + self.command.style = "green" if valid else "red" + + return valid + + async def on_mount(self) -> None: + self.outputs_scroll = textual.widgets.ScrollView(self.outputs) + grid = await self.view.dock_grid(edge="left") + grid.add_column(fraction=1, name="c0") + grid.add_row(size=2, name="r0") + grid.add_row(size=3, name="r1") + grid.add_row(size=3, name="r2") + grid.add_row(size=1, name="r3") + grid.add_row(size=2, name="r4") + grid.add_row(fraction=1, name="r5") + grid.add_areas( + command="c0,r2", + header="c0,r0", + usage="c0,r1", + outputs="c0,r5", + outputs_title="c0,r4", + ) + grid.place( + command=self.command, + header=self.header, + outputs=self.outputs_scroll, + outputs_title=self.outputs_title, + usage=self.usage, + ) + + +def _help_picking_attr(src: str, attrs: List[str]) -> str: + cache = join(MAKES_DIR, "cache", "last.json") + initial_input = "/" + if exists(cache): + with open(cache, encoding="utf-8") as file: + initial_input = file.read() + + TextUserInterface.run(attrs=attrs, initial_input=initial_input, src=src) + + with open(cache, encoding="utf-8", mode="w") as file: + file.write(shlex.join(sys.argv[2:])) + + if sys.argv[2:]: + return sys.argv[2] + + CON.print() + CON.print("No command was typed during the prompt", justify="center") + CON.print() + CON.print("Please see the correct usage below", justify="center") + _help_and_exit_with_src_no_tty(src, attrs) + return "" + + def cli(args: List[str]) -> None: CON.out() CON.print(":unicorn_face: [b]Makes[/b]", justify="center") @@ -535,15 +743,17 @@ def cli(args: List[str]) -> None: if args[1:]: src: str = args[1] else: - _help_and_exit() + _help_and_exit_base() head: str = _get_head(src) attrs: List[str] = _get_attrs(head) if args[2:]: attr: str = args[2] + elif CON.is_terminal: + attr = _help_picking_attr(src, attrs) else: - _help_and_exit(src, attrs) + _help_and_exit_with_src_no_tty(src, attrs) cache: List[Dict[str, str]] = _get_cache(head) CON.out() @@ -555,7 +765,7 @@ def cli(args: List[str]) -> None: CON.print("It is not a valid project output", justify="center") CON.print() CON.print("Please see the correct usage below", justify="center") - _help_and_exit(src, attrs) + _help_and_exit_with_src_no_tty(src, attrs) out: str = join(MAKES_DIR, f"out{attr.replace('/', '-')}") code, _, _ = _run( @@ -601,7 +811,7 @@ def cache_push(cache: List[Dict[str, str]], out: str) -> None: _run( args=["cachix", "push", "-c", "0", config["name"], out], stderr=None, - stdout=None, + stdout=sys.stderr.fileno(), ) return