From a856963f5be72af5ff0385fd7a9f961179b7a39b Mon Sep 17 00:00:00 2001 From: Kevin Amado Date: Sun, 25 Sep 2022 13:24:17 -0600 Subject: [PATCH] test(back): #919 add tests - Add a few unit and functinoal tests Signed-off-by: Kevin Amado --- .github/workflows/dev.yml | 17 + .github/workflows/prod.yml | 21 + makes.nix | 29 +- makes/cli/env/test/main.nix | 8 + makes/cli/env/test/pypi-deps.yaml | 3 + makes/cli/env/test/pypi-deps.yaml.license | 3 + makes/cli/env/test/pypi-sources.yaml | 102 ++ makes/cli/env/test/pypi-sources.yaml.license | 3 + makes/license/entrypoint.sh | 6 +- src/cli/main/__main__.py | 1059 +----------------- src/cli/main/cli.py | 792 +++++++++++++ src/cli/main/emojis.py | 94 ++ src/cli/main/tui.py | 204 ++++ src/cli/test_all.py | 78 ++ 14 files changed, 1354 insertions(+), 1065 deletions(-) create mode 100644 makes/cli/env/test/main.nix create mode 100644 makes/cli/env/test/pypi-deps.yaml create mode 100644 makes/cli/env/test/pypi-deps.yaml.license create mode 100644 makes/cli/env/test/pypi-sources.yaml create mode 100644 makes/cli/env/test/pypi-sources.yaml.license create mode 100644 src/cli/main/cli.py create mode 100644 src/cli/main/emojis.py create mode 100644 src/cli/main/tui.py create mode 100644 src/cli/test_all.py diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 54ac1298..3d7a3385 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -501,6 +501,23 @@ jobs: - name: /taintTerraform/module run: nix-env -if . && m . /taintTerraform/module + linux_testPython_cliMain: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b + - uses: docker://docker.io/nixos/nix@sha256:1d13ae379fb8caf3f859c5ce7ec6002643d60cf8b7b6147b949cc34880c93bac + name: /testPython/cliMain + with: + set-safe-directory: /github/workspace + args: sh -c "nix-env -if . && m . /testPython/cliMain" + macos_testPython_cliMain: + runs-on: macos-latest + steps: + - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b + - uses: cachix/install-nix-action@451e61183802597c1febd6ca3cf18aa163f93a06 + - name: /testPython/cliMain + run: nix-env -if . && m . /testPython/cliMain + linux_testPython_example: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index e8ab073e..83cce53e 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -98,6 +98,27 @@ jobs: env: CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} + linux_dev_cliMain: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b + - uses: docker://docker.io/nixos/nix@sha256:1d13ae379fb8caf3f859c5ce7ec6002643d60cf8b7b6147b949cc34880c93bac + name: /dev/cliMain + with: + set-safe-directory: /github/workspace + args: sh -c "nix-env -if . && m . /dev/cliMain" + env: + CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} + macos_dev_cliMain: + runs-on: macos-latest + steps: + - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b + - uses: cachix/install-nix-action@451e61183802597c1febd6ca3cf18aa163f93a06 + - name: /dev/cliMain + run: nix-env -if . && m . /dev/cliMain + env: + CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} + linux_dev_example: runs-on: ubuntu-latest steps: diff --git a/makes.nix b/makes.nix index 40334a9a..df5ea636 100644 --- a/makes.nix +++ b/makes.nix @@ -6,6 +6,7 @@ fetchNixpkgs, inputs, outputs, + projectPath, ... }: { projectIdentifier = "makes-repo"; @@ -133,9 +134,7 @@ targets = ["/"]; }; lintPython = let - searchPaths = { - source = [outputs."/cli/env/runtime/pypi"]; - }; + searchPaths.source = [outputs."/cli/env/runtime"]; in { dirsOfModules = { makes = { @@ -210,7 +209,7 @@ securePythonWithBandit = { cli = { python = "3.9"; - target = "/src/cli"; + target = "/src/cli/main"; }; }; taintTerraform = { @@ -227,6 +226,28 @@ python = "3.9"; src = "/test/test-python"; }; + cliMain = { + python = "3.9"; + extraFlags = [ + "--cov=main" + "--cov-branch" + "--cov-report=term-missing" + "--capture=no" + ]; + searchPaths = { + bin = [ + inputs.nixpkgs.git + ]; + pythonPackage = [ + (projectPath "/src/cli/main") + ]; + source = [ + outputs."/cli/env/test" + outputs."/cli/env/runtime" + ]; + }; + src = "/src/cli"; + }; }; testTerraform = { modules = { diff --git a/makes/cli/env/test/main.nix b/makes/cli/env/test/main.nix new file mode 100644 index 00000000..c6be1c98 --- /dev/null +++ b/makes/cli/env/test/main.nix @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2022 Fluid Attacks and Makes contributors +# +# SPDX-License-Identifier: MIT +{makePythonPypiEnvironment, ...}: +makePythonPypiEnvironment { + name = "cli-env-test"; + sourcesYaml = ./pypi-sources.yaml; +} diff --git a/makes/cli/env/test/pypi-deps.yaml b/makes/cli/env/test/pypi-deps.yaml new file mode 100644 index 00000000..9433fa80 --- /dev/null +++ b/makes/cli/env/test/pypi-deps.yaml @@ -0,0 +1,3 @@ +--- +pytest: '*' +pytest-cov: '*' diff --git a/makes/cli/env/test/pypi-deps.yaml.license b/makes/cli/env/test/pypi-deps.yaml.license new file mode 100644 index 00000000..41b2878e --- /dev/null +++ b/makes/cli/env/test/pypi-deps.yaml.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2022 Fluid Attacks and Makes contributors + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/makes/cli/env/test/pypi-sources.yaml b/makes/cli/env/test/pypi-sources.yaml new file mode 100644 index 00000000..fd7c6bfb --- /dev/null +++ b/makes/cli/env/test/pypi-sources.yaml @@ -0,0 +1,102 @@ +--- +closure: + attrs: 22.1.0 + colorama: 0.4.5 + coverage: 6.4.4 + iniconfig: 1.1.1 + packaging: '21.3' + pluggy: 1.0.0 + py: 1.11.0 + pyparsing: 3.0.9 + pytest: 7.1.3 + pytest-cov: 3.0.0 + tomli: 2.0.1 +links: + - name: attrs-22.1.0-py2.py3-none-any.whl + sha256: 072mv8qgvas8sagx7f021l9yrca6ry3m8cqsylsdzwkvyq1a9vw6 + url: https://files.pythonhosted.org/packages/f2/bc/d817287d1aa01878af07c19505fafd1165cd6a119e9d0821ca1d1c20312d/attrs-22.1.0-py2.py3-none-any.whl + - name: attrs-22.1.0.tar.gz + sha256: 1di2kd18bc0sdq61sa24sdr9c7xjg3g8ymkw1qfikra7aikc5b99 + url: https://files.pythonhosted.org/packages/1a/cb/c4ffeb41e7137b23755a45e1bfec9cbb76ecf51874c6f1d113984ecaa32c/attrs-22.1.0.tar.gz + - name: colorama-0.4.5-py2.py3-none-any.whl + sha256: 1nlriqsqjsilvxg6pm3086z2xkjviplw3gz79a1gadryjd2g8jw5 + url: https://files.pythonhosted.org/packages/77/8b/7550e87b2d308a1b711725dfaddc19c695f8c5fa413c640b2be01662f4e6/colorama-0.4.5-py2.py3-none-any.whl + - name: colorama-0.4.5.tar.gz + sha256: 195pcxlhp4qz8pq496kvw6k7vdd056j8mffr76k8h2f59wrv9ip6 + url: https://files.pythonhosted.org/packages/2b/65/24d033a9325ce42ccbfa3ca2d0866c7e89cc68e5b9d92ecaba9feef631df/colorama-0.4.5.tar.gz + - name: coverage-6.4.4-cp39-cp39-macosx_10_9_x86_64.whl + sha256: 11hvxg1bvf4ycy7drqflf3cghf52c7wnrrh01bdkr2bjnplvkh4q + url: https://files.pythonhosted.org/packages/fd/1a/8b2f6aabf828e9795855001848ce72370819fffca883714eb25aa6d00b38/coverage-6.4.4-cp39-cp39-macosx_10_9_x86_64.whl + - name: coverage-6.4.4-cp39-cp39-macosx_11_0_arm64.whl + sha256: 0239l3h9d1jx0lhg529yxygcwipv76iys5sqv2hks9wvq5p0yq7w + url: https://files.pythonhosted.org/packages/ca/0e/e60fbc65bc054d904984ccd2a52c122fd291c682b5713b37dbb978f853bb/coverage-6.4.4-cp39-cp39-macosx_11_0_arm64.whl + - name: coverage-6.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 0kxcbgm91gl6nwicadn7hly4ikahac64wyqcc1xc19acdnzxd63s + url: https://files.pythonhosted.org/packages/26/e7/dbbfe7288d846b372229794d7c5f48d55e519d30e1419febea7b1e71478c/coverage-6.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - name: coverage-6.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl + sha256: 1wl88zmwdppglfl4f5r5j6xl9ic5x0mz88g24id71gbx15lqfxq1 + url: https://files.pythonhosted.org/packages/35/3d/d79b2b927e7cb8dded2a003d22348b918509fe1238ff796a0b2fde9b3718/coverage-6.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl + - name: coverage-6.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 0n053s2h8a4pq3lminpvr1liz3s04wdhfrj1n8m5w984p5zbk86z + url: https://files.pythonhosted.org/packages/1c/59/1d3dae9d3821b81dc17b06438c6918060388457272af20856d42a8bb30b7/coverage-6.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - name: coverage-6.4.4-cp39-cp39-musllinux_1_1_aarch64.whl + sha256: 068a99gdns7bq0idzgl9wsvhdkdjgr8pwkbki2mzh4z0afd3vgpw + url: https://files.pythonhosted.org/packages/16/99/e69b196431c25d7fa845bdd8265a0f111d3e036c4b1cac7a98cb4e5a07b9/coverage-6.4.4-cp39-cp39-musllinux_1_1_aarch64.whl + - name: coverage-6.4.4-cp39-cp39-musllinux_1_1_i686.whl + sha256: 03bs3iln6y6f8i2y8nnzlpbi47qyzdbi91csrhnfj9727s2qvqqm + url: https://files.pythonhosted.org/packages/08/e4/9a7556b46a646520bda96f53c3fe691c9c5f7ebd0b64fc6aea8e829b75e7/coverage-6.4.4-cp39-cp39-musllinux_1_1_i686.whl + - name: coverage-6.4.4-cp39-cp39-musllinux_1_1_x86_64.whl + sha256: 06im6pvldvj8439gw67qn3mv701yihbcaf96a6r8mzyywbgds4v9 + url: https://files.pythonhosted.org/packages/f9/f5/d3bea1146b7ee22323b667abc029e3be962a162e036e9d30459e6d554a8a/coverage-6.4.4-cp39-cp39-musllinux_1_1_x86_64.whl + - name: coverage-6.4.4.tar.gz + sha256: 0n0dygbm5an42whz6f1ln1593cqh7inji2zqwvhq1dxc4svlav71 + url: https://files.pythonhosted.org/packages/79/f3/8c1af7233f874b5df281397e2b96bedf58dc440bd8c6fdbf93a4436c517a/coverage-6.4.4.tar.gz + - name: iniconfig-1.1.1-py2.py3-none-any.whl + sha256: 1cx8kpp3akxwadzsmv2cdnifkyzj8fki5frmv3mzcivz9g3287h1 + url: https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl + - name: iniconfig-1.1.1.tar.gz + sha256: 0ckzngs3scaa1mcfmsi1w40a1l8cxxnncscrxzjjwjyisx8z0fmw + url: https://files.pythonhosted.org/packages/23/a2/97899f6bd0e873fed3a7e67ae8d3a08b21799430fb4da15cfedf10d6e2c2/iniconfig-1.1.1.tar.gz + - name: packaging-21.3-py3-none-any.whl + sha256: 08nmbgmf38nnxr99d5nlnacrr2jh1wp4xsi4ms1wgk8ryl2kw47g + url: https://files.pythonhosted.org/packages/05/8e/8de486cbd03baba4deef4142bd643a3e7bbe954a784dc1bb17142572d127/packaging-21.3-py3-none-any.whl + - name: packaging-21.3.tar.gz + sha256: 1sygirdrqgv4f1ckh9nhpcw1yfidrh3qjl86wq8vk6nq4wlw8iyx + url: https://files.pythonhosted.org/packages/df/9e/d1a7217f69310c1db8fdf8ab396229f55a699ce34a203691794c5d1cad0c/packaging-21.3.tar.gz + - name: pluggy-1.0.0-py2.py3-none-any.whl + sha256: 1lvvnc39v43v5jawq2wz0765rg9lkx8f25l4sqv1l0vz8nzln4vl + url: https://files.pythonhosted.org/packages/9e/01/f38e2ff29715251cf25532b9082a1589ab7e4f571ced434f98d0139336dc/pluggy-1.0.0-py2.py3-none-any.whl + - name: pluggy-1.0.0.tar.gz + sha256: 0n8iadlas2z1b4h0fc73b043c7iwfvx9rgvqm1azjmffmhxkf922 + url: https://files.pythonhosted.org/packages/a1/16/db2d7de3474b6e37cbb9c008965ee63835bba517e22cdb8c35b5116b5ce1/pluggy-1.0.0.tar.gz + - name: py-1.11.0-py2.py3-none-any.whl + sha256: 0y5k0mjcr85i9xrd2wifc7gww596nq6dbz6d9bzpsr1jhwhm6z30 + url: https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl + - name: py-1.11.0.tar.gz + sha256: 06c7m7sfcn7587xd4s2bng8m6q1gsfd3j93afhplfjq74r0mrisi + url: https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz + - name: pyparsing-3.0.9-py3-none-any.whl + sha256: 1g3b426kswh9ndjdlkpf9ba0fhwz5c2hjbxb3nvfzshfl7lvl9jh + url: https://files.pythonhosted.org/packages/6c/10/a7d0fa5baea8fe7b50f448ab742f26f52b80bfca85ac2be9d35cdd9a3246/pyparsing-3.0.9-py3-none-any.whl + - name: pyparsing-3.0.9.tar.gz + sha256: 1yvhdm0wdc1n20fzl0qly13llr0zcg9wl7mp37r8gdi1gp7hw0ib + url: https://files.pythonhosted.org/packages/71/22/207523d16464c40a0310d2d4d8926daffa00ac1f5b1576170a32db749636/pyparsing-3.0.9.tar.gz + - name: pytest-7.1.3-py3-none-any.whl + sha256: 1dqqm2yz0z63cywjjilnqwcz5z3wpdazmv2w7xgbaw3d8sivsxqk + url: https://files.pythonhosted.org/packages/e3/b9/3541bbcb412a9fd56593005ff32183825634ef795a1c01ceb6dee86e7259/pytest-7.1.3-py3-none-any.whl + - name: pytest-7.1.3.tar.gz + sha256: 0f8c31v5r2kgjixvy267n0nhc4xsy65g3n9lz1i1377z5pn5ydjg + url: https://files.pythonhosted.org/packages/a4/a7/8c63a4966935b0d0b039fd67ebf2e1ae00f1af02ceb912d838814d772a9a/pytest-7.1.3.tar.gz + - name: pytest_cov-3.0.0-py3-none-any.whl + sha256: 19m75ii6drx6x5nvpwzfknpdm7xh0mdbhf69c7wya9aamhamv3ap + url: https://files.pythonhosted.org/packages/20/49/b3e0edec68d81846f519c602ac38af9db86e1e71275528b3e814ae236063/pytest_cov-3.0.0-py3-none-any.whl + - name: pytest-cov-3.0.0.tar.gz + sha256: 0w6lfv8gc1lxmnvsz7mq5z9shxac5zz6s9mwrai108kxc6qzbw77 + url: https://files.pythonhosted.org/packages/61/41/e046526849972555928a6d31c2068410e47a31fb5ab0a77f868596811329/pytest-cov-3.0.0.tar.gz + - name: tomli-2.0.1-py3-none-any.whl + sha256: 1k0fqfdylinb57s2aqwprahwbrsk3babg4gghz4g06hnlvky77ck + url: https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl + - name: tomli-2.0.1.tar.gz + sha256: 0kwazq3i18rphcr8gak4fgzdcj5w5bbn4k4j2l6ma32gj496qlny + url: https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz +python: '3.9' diff --git a/makes/cli/env/test/pypi-sources.yaml.license b/makes/cli/env/test/pypi-sources.yaml.license new file mode 100644 index 00000000..41b2878e --- /dev/null +++ b/makes/cli/env/test/pypi-sources.yaml.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2022 Fluid Attacks and Makes contributors + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/makes/license/entrypoint.sh b/makes/license/entrypoint.sh index 2cfc0dd9..333bb5bc 100644 --- a/makes/license/entrypoint.sh +++ b/makes/license/entrypoint.sh @@ -40,11 +40,7 @@ function main { done \ && find . "${remaining[@]}" -type f \ -exec "${license[@]}" --explicit-license {} \+ \ - && : - - if test "${success}" != "true"; then - critical "Some files are missing licensing information. When this command fails it adds the propper licensing notices to the files that need it, please commit those changes." - fi + && critical "Some files are missing licensing information. When this command fails it adds the propper licensing notices to the files that need it, please commit those changes." } main "${@}" diff --git a/src/cli/main/__main__.py b/src/cli/main/__main__.py index e777781b..29b9e651 100644 --- a/src/cli/main/__main__.py +++ b/src/cli/main/__main__.py @@ -2,1063 +2,10 @@ # # SPDX-License-Identifier: MIT -# pylint: disable=too-many-lines -from contextlib import ( - suppress, +from cli import ( + main, ) -from functools import ( - partial, -) -from hashlib import ( - sha256, -) -import io -import json -import operator -import os -from os import ( - environ, - getcwd, - getlogin, - makedirs, - remove, -) -from os.path import ( - commonprefix, - exists, - getctime, - join, - realpath, -) -from posixpath import ( - abspath, - dirname, -) -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 -from socket import ( - gethostname, -) -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, -) -from typing import ( - Any, - Callable, - Dict, - List, - NamedTuple, - Optional, - Set, - Tuple, -) -from urllib.parse import ( - quote_plus as url_quote, -) -import warnings - -CWD: str = getcwd() -CON: rich.console.Console = rich.console.Console( - highlight=False, - 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 = "22.10" - -# Environment -__MAKES_SRC__: str = environ["__MAKES_SRC__"] -__NIX_STABLE__: str = environ["__NIX_STABLE__"] -__NIX_UNSTABLE__: str = environ["__NIX_UNSTABLE__"] - - -# Feature flags -AWS_BATCH_COMPAT: bool = bool(environ.get("MAKES_AWS_BATCH_COMPAT")) -if AWS_BATCH_COMPAT: - CON.out("Using feature flag: MAKES_AWS_BATCH_COMPAT") - CON.out() - -GIT_DEPTH: int = int(environ.get("MAKES_GIT_DEPTH", "1")) -if GIT_DEPTH != 1: - CON.out(f"Using feature flag: MAKES_GIT_DEPTH={GIT_DEPTH}") - - -K8S_COMPAT: bool = bool(environ.get("MAKES_K8S_COMPAT")) -if K8S_COMPAT: - CON.out("Using feature flag: MAKES_K8S_COMPAT") - -NIX_STABLE: bool = not bool(environ.get("MAKES_NIX_UNSTABLE")) -if not NIX_STABLE: - CON.out("Using feature flag: MAKES_NIX_UNSTABLE") - - -# Constants -EMOJIS_FAILURE = [ - "alien_monster", # 👾 - "anxious_face_with_sweat", # 😰 - "beetle", # 🐞 - "blowfish", # 🐡 - "brick", # 🧱 - "broken_heart", # 💔 - "bug", # 🐛 - "collision", # 💥 - "dizzy_face", # 😵 - "exploding_head", # 🤯 - "eyes", # 👀 - "face_with_monocle", # 🧐 - "fire", # 🔥 - "ghost", # 👻 - "lady_beetle", # 🐞 - "mega", # 📣 - "microscope", # 🔬 - "moai", # 🗿 - "open_mouth", # 😮 - "person_facepalming", # 🤦 - "person_getting_massage", # 💆 - "sad_but_relieved_face", # 😥 - "see_no_evil", # 🙈 - "smiling_imp", # 😈 - "speak_no_evil", # 🙊 - "thinking_face", # 🤔 - "upside__down_face", # 🙃 - "volcano", # 🌋 - "wilted_flower", # 🥀 - "woozy_face", # 🥴 - "yawning_face", # 🥱 - "zipper__mouth_face", # 🤐 -] -EMOJIS_SUCCESS = [ - "airplane_departure", # 🛫 - "beer", # 🍺 - "beers", # 🍻 - "birthday", # 🎂 - "bottle_with_popping_cork", # 🍾 - "bouquet", # 💐 - "bulb", # 💡 - "blossom", # 🌼 - "boxing_glove", # 🥊 - "call_me_hand", # 🤙 - "cat", # 🐱 - "clapping_hands", # 👏 - "clinking_glasses", # 🥂 - "colombia", # 🇨🇴 - "confetti_ball", # 🎊 - "couple_with_heart", # 💑 - "checkered_flag", # 🏁 - "crown", # 👑 - "dart", # 🎯 - "dog", # 🐶 - "dancer", # 💃 - "doughnut", # 🍩 - "eagle", # 🦅 - "elephant", # 🐘 - "face_blowing_a_kiss", # 😘 - "flamingo", # 🦩 - "four_leaf_clover", # 🍀 - "fries", # 🍟 - "glowing_star", # 🌟 - "kite", # 🪁 - "mage", # 🧙 - "merperson", # 🧜 - "money_with_wings", # 💸 - "nail_care", # 💅 - "party_popper", # 🎉 - "partying_face", # 🥳 - "person_cartwheeling", # 🤸 - "person_playing_handball", # 🤾 - "person_playing_water_polo", # 🤽 - "person_surfing", # 🏄 - "pizza", # 🍕 - "popcorn", # 🍿 - "rainbow", # 🌈 - "shooting_star", # 🌠 - "smiling_face_with_sunglasses", # 😎 - "smirk", # 😏 - "rocket", # 🚀 - "trophy", # 🏆 - "whale", # 🐳 - "wink", # 😉 -] - - -def _if(condition: Any, *value: Any) -> List[Any]: - return list(value) if condition else [] - - -def _clone_src(src: str) -> str: - # pylint: disable=consider-using-with - 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 .` ? - if NIX_STABLE: - _add_safe_directory() - _clone_src_git_worktree_add(src, head) - else: - # Nix with Flakes already ensures a pristine git repo - head = src - else: - _add_safe_directory() - if ( - (match := _clone_src_github(src)) - or (match := _clone_src_gitlab(src)) - or (match := _clone_src_local(src)) - ): - cache_key, remote, rev = match - else: - CON.print(f"We can't proceed with SOURCE: {src}", justify="center") - CON.print("It has an unrecognized format", justify="center") - CON.print() - CON.print("Please see the correct usage below", justify="center") - _help_and_exit_base() - - _clone_src_git_init(head) - remote = _clone_src_cache_get(src, cache_key, remote) - _clone_src_git_fetch(head, remote, rev) - _clone_src_git_checkout(head, rev) - _clone_src_cache_refresh(head, cache_key) - - return head - - -def _add_safe_directory() -> None: - cmd = [ - "git", - "config", - "--global", - "--add", - "safe.directory", - "/github/workspace", - ] - out = _run(cmd, stderr=None, stdout=sys.stderr.fileno()) - if out != 0: - raise SystemExit(out) - - -def _clone_src_git_init(head: str) -> None: - cmd = ["git", "init", "--initial-branch=____", "--shared=false", head] - out = _run(cmd, stderr=None, stdout=sys.stderr.fileno()) - if out != 0: - raise SystemExit(out) - - -def _clone_src_git_rev_parse(head: str, rev: str) -> str: - cmd = ["git", "-C", head, "rev-parse", rev] - out, stdout, _ = _run_outputs(cmd, stderr=None) - if out != 0: - raise SystemExit(out) - - return next(iter(stdout.decode().splitlines()), "HEAD") - - -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=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=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=sys.stderr.fileno()) - if out != 0: - raise SystemExit(out) - CON.out(head) - - -def _clone_src_github(src: str) -> Optional[Tuple[str, str, str]]: - regex = r"^github:(?P.*)/(?P.*)@(?P.*)$" - - if match := re.match(regex, src): - owner = url_quote(match.group("owner")) - repo = url_quote(match.group("repo")) - rev = url_quote(match.group("rev")) - remote = f"https://github.com/{owner}/{repo}" - cache_key = f"github-{owner}-{repo}-{rev}" - - return cache_key, remote, rev - - return None - - -def _clone_src_gitlab(src: str) -> Optional[Tuple[str, str, str]]: - regex = r"^gitlab:(?P.*)/(?P.*)@(?P.*)$" - - if match := re.match(regex, src): - owner = url_quote(match.group("owner")) - repo = url_quote(match.group("repo")) - rev = url_quote(match.group("rev")) - remote = f"https://gitlab.com/{owner}/{repo}.git" - cache_key = f"gitlab-{owner}-{repo}-{rev}" - - return cache_key, remote, rev - - return None - - -def _clone_src_local(src: str) -> Optional[Tuple[str, str, str]]: - regex = r"^local:(?P.*)@(?P.*)$" - - if match := re.match(regex, src): - path = url_quote(match.group("path")) - rev = url_quote(match.group("rev")) - remote = f"file://{path}" - cache_key = "" - - return cache_key, remote, rev - - return None - - -def _clone_src_cache_get(src: str, cache_key: str, remote: str) -> str: - cached: str = join(SOURCES_CACHE, cache_key) - if cache_key: - if exists(cached): - cached_since: float = time() - getctime(cached) - if cached_since <= 86400.0: - CON.out(f"Cached from {cached}") - remote = cached - else: - shutil.rmtree(cached) - else: - CON.out(f"From {src}") - - return remote - - -def _clone_src_cache_refresh(head: str, cache_key: str) -> None: - cached: str = join(SOURCES_CACHE, cache_key) - if cache_key and not exists(cached): - shutil.copytree(head, cached) - - -def _nix_build( - *, - attr: str, - cache: Optional[List[Dict[str, str]]], - head: str, - out: str = "", -) -> List[str]: - if cache is None: - substituters = "https://cache.nixos.org" - trusted_pub_keys = ( - "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" - ) - else: - substituters = " ".join(map(operator.itemgetter("url"), cache)) - trusted_pub_keys = " ".join(map(operator.itemgetter("pubKey"), cache)) - - return [ - *_if(NIX_STABLE, f"{__NIX_STABLE__}/bin/nix-build"), - *_if(not NIX_STABLE, f"{__NIX_UNSTABLE__}/bin/nix"), - *_if(not NIX_STABLE, "--experimental-features", "flakes nix-command"), - *_if(not NIX_STABLE, "build"), - *_if(NIX_STABLE, "--argstr", "makesSrc", __MAKES_SRC__), - *_if(NIX_STABLE, "--argstr", "projectSrc", head), - *_if(NIX_STABLE, "--attr", attr), - *["--option", "cores", "0"], - *_if(not NIX_STABLE, "--impure"), - *["--option", "narinfo-cache-negative-ttl", "1"], - *["--option", "narinfo-cache-positive-ttl", "1"], - *["--option", "max-jobs", "auto"], - *["--option", "substituters", substituters], - *["--option", "trusted-public-keys", trusted_pub_keys], - *["--option", "sandbox", "false" if K8S_COMPAT else "true"], - *_if(out, "--out-link", out), - *_if(not out, "--no-out-link"), - *["--show-trace"], - *_if(NIX_STABLE, f"{__MAKES_SRC__}/src/evaluator/default.nix"), - *_if(not NIX_STABLE, attr), - ] - - -def _nix_hashes(*paths: str) -> List[str]: - cmd = [ - f"{__NIX_STABLE__}/bin/nix-store", - "--query", - "--hash", - *paths, - ] - out, stdout, _ = _run_outputs(cmd, stderr=None) - if out != 0: - raise SystemExit(out) - - return stdout.decode().splitlines() - - -def _nix_build_requisites(path: str) -> List[Tuple[str, str]]: - """Answer the question: what do I need to build `out`.""" - cmd = [f"{__NIX_STABLE__}/bin/nix-store", "--query", "--deriver", path] - out, stdout, _ = _run_outputs(cmd, stderr=None) - if out != 0: - raise SystemExit(out) - - cmd = [ - f"{__NIX_STABLE__}/bin/nix-store", - "--query", - "--requisites", - "--include-outputs", - *stdout.decode().splitlines(), - ] - out, stdout, _ = _run_outputs(cmd, stderr=None) - if out != 0: - raise SystemExit(out) - - requisites: List[str] = stdout.decode().splitlines() - - hashes: List[str] = _nix_hashes(*requisites) - - return list(zip(requisites, hashes)) - - -def _get_head(src: str) -> str: - # Checkout repository HEAD into a temporary directory - # This is nice for reproducibility and security, - # files not in the HEAD commit are left out of the build inputs - CON.out() - CON.rule(f"Fetching {src}") - CON.out() - head: str = _clone_src(src) - - # Applies only to local repositories on non-flakes Nix - if abspath(src) == CWD and NIX_STABLE: # `m .` ? - paths: Set[str] = set() - - # Propagated `git add`ed files - cmd = ["git", "-C", src, "diff", "--cached", "--name-only"] - out, stdout, _ = _run_outputs(cmd, stderr=None) - if out != 0: - raise SystemExit(out) - paths.update(stdout.decode().splitlines()) - - # Propagated modified files - cmd = ["git", "-C", src, "ls-files", "--modified"] - out, stdout, _ = _run_outputs(cmd, stderr=None) - if out != 0: - raise SystemExit(out) - paths.update(stdout.decode().splitlines()) - - # Copy paths to head - for path in sorted(paths): - dest = join(head, path) - path = join(src, path) - if not exists(dirname(dest)): - makedirs(dirname(dest)) - if exists(path): - shutil.copy(path, dest) - else: - remove(dest) - - return head - - -class Config(NamedTuple): - attrs: List[str] - cache: List[Dict[str, str]] - - -def _get_config(head: str) -> Config: - CON.out() - CON.rule("Building project configuration") - CON.out() - out: str = tempfile.mktemp() # nosec - code = _run( - args=_nix_build( - attr="config.configAsJson" - if NIX_STABLE - else f'{head}#__makes__."config:configAsJson"', - cache=None, - head=head, - out=out, - ), - env=None if NIX_STABLE else dict(HOME=environ["HOME_IMPURE"]), - stderr=None, - stdout=sys.stderr.fileno(), - ) - - if code == 0: - with open(out, encoding="utf-8") as file: - config: Dict[str, Any] = json.load(file) - - return Config(attrs=config["outputs"], cache=config["cache"]) - - raise SystemExit(code) - - -def _run_outputs( # pylint: disable=too-many-arguments - args: List[str], - cwd: Optional[str] = None, - env: Optional[Dict[str, str]] = None, - stdout: Optional[int] = subprocess.PIPE, - stderr: Optional[int] = subprocess.PIPE, - stdin: Optional[bytes] = None, -) -> Tuple[int, bytes, bytes]: - env = environ | (env or {}) - - with subprocess.Popen( - args=args, - cwd=cwd, - env=env, - shell=False, # nosec - stdin=None if stdin is None else subprocess.PIPE, - stdout=stdout, - stderr=stderr, - ) as process: - out, err = process.communicate(stdin) - - return process.returncode, out, err - - -def _run( # pylint: disable=too-many-arguments - args: List[str], - cwd: Optional[str] = None, - env: Optional[Dict[str, str]] = None, - stdout: Optional[int] = None, - stderr: Optional[int] = None, - stdin: Optional[bytes] = None, -) -> int: - env = environ | (env or {}) - - with subprocess.Popen( - args=args, - cwd=cwd, - env=env, - shell=False, # nosec - stdin=None if stdin is None else subprocess.PIPE, - stdout=stdout, - stderr=stderr, - ) as process: - return process.wait() - - -def _help_and_exit_base() -> None: - CON.out() - CON.rule("Usage") - CON.out() - - text = "$ m SOURCE" - CON.print(rich.panel.Panel.fit(text), justify="center") - CON.out() - - text = """ - Can be: - - A git repository in the current working directory: - $ m . - - A git repository and revision: - $ m local:/path/to/repo@rev - - A GitHub repository and revision: - $ m github: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() - - 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)." - CON.print(rich.panel.Panel(text, title="ARGS")) - - 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): - output = textual.reactive.Reactive("") - outputs = textual.reactive.Reactive([]) - - def render(self) -> rich.text.Text: - if self.outputs: - longest = max(map(len, self.outputs)) - text = rich.text.Text() - for output in self.outputs: - text.append(self.output, style="yellow") - text.append(output[len(self.output) :]) - text.append(" " * (longest - len(output))) - text.append("\n") - else: - text = rich.text.Text("(none)") - return rich.align.Align(text, align="center") # type: ignore - - -class TuiOutputsTitle(textual.widget.Widget): - output = textual.reactive.Reactive("") - - def render(self) -> rich.text.Text: - text = rich.text.Text("Outputs starting with: ", justify="center") - text.append(self.output, style="yellow") - return text - - -class TextUserInterface(textual.app.App): - # pylint: disable=too-many-instance-attributes - def __init__( - self, - *args: Any, - src: str, - attrs: List[str], - initial_input: str, - state: Dict[str, Any], - **kwargs: Any, - ) -> None: - self.attrs = attrs - self.src = src - self.state = state - - 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(): - self.state["return"] = [self.output, *self.args] - await self.action_quit() - else: - self.input += event.key - self.propagate_data(autocomplete=True) - await self.outputs_scroll.update(self.outputs) # type: ignore - - 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.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]) -> List[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() - - state: Dict[str, Any] = {} - TextUserInterface.run( - attrs=attrs, initial_input=initial_input, state=state, src=src - ) - - if "return" in state: - with open(cache, encoding="utf-8", mode="w") as file: - file.write(shlex.join(state["return"])) - return state["return"] - - 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") - CON.print(f"v{VERSION}-{sys.platform}", justify="center") - if args[1:]: - src: str = args[1] - else: - _help_and_exit_base() - - head: str = _get_head(src) - config: Config = _get_config(head) - - args, attr = _cli_get_args_and_attr(args, config.attrs, src) - - out: str = join(MAKES_DIR, f"out{attr.replace('/', '-')}") - provenance: str = join( - MAKES_DIR, - f"provenance{attr.replace('/', '-')}.json", - ) - code = _cli_build(attr, config, head, out, src) - - if code == 0: - write_provenance(args, head, out, provenance, src) - cache_push(config.cache, out) - execute_action(args[3:], head, out) - - raise SystemExit(code) - - -def _cli_get_args_and_attr( - args: List[str], - attrs: List[str], - src: str, -) -> Tuple[List[str], str]: - if args[2:]: - attr: str = args[2] - elif CON.is_terminal: - args = [*args[0:2], *_help_picking_attr(src, attrs)] - attr = args[2] - else: - _help_and_exit_with_src_no_tty(src, attrs) - - return args, attr - - -def _cli_build( - attr: str, - config: Config, - head: str, - out: str, - src: str, -) -> int: - CON.out() - CON.rule(f"Building {attr}") - CON.out() - if attr not in config.attrs: - CON.print(f"We can't proceed with OUTPUT: {attr}", justify="center") - 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_with_src_no_tty(src, config.attrs) - - code = _run( - args=_nix_build( - attr=f'config.outputs."{attr}"' - if NIX_STABLE - else f'{head}#__makes__."config:outputs:{attr}"', - cache=config.cache, - head=head, - out=out, - ), - env=None if NIX_STABLE else dict(HOME=environ["HOME_IMPURE"]), - stderr=None, - stdout=None, - ) - - return code - - -def execute_action(args: List[str], head: str, out: str) -> None: - action_path: str = join(out, "makes-action.sh") - - if exists(action_path): - CON.out() - CON.rule("Running") - CON.out() - code = _run( - args=[action_path, out, *args], - stderr=None, - stdout=None, - cwd=head if AWS_BATCH_COMPAT else CWD, - ) - raise SystemExit(code) - - -def cache_push(cache: List[Dict[str, str]], out: str) -> None: - once: bool = True - for config in cache: - if config["type"] == "cachix" and "CACHIX_AUTH_TOKEN" in environ: - if once: - CON.rule("Pushing to cache") - once = False - _run( - args=["cachix", "push", "-c", "0", config["name"], out], - stderr=None, - stdout=sys.stderr.fileno(), - ) - return - - -def _get_sys_id() -> str: - with suppress(AttributeError): - uname = os.uname() - return f"{uname.nodename}-{uname.sysname}-{uname.machine}" - - with suppress(OSError): - return gethostname() - - return "unknown" - - -def _get_usr() -> str: - with suppress(OSError): - return getlogin() - - return "unknown" - - -def write_provenance( - args: List[str], - head: str, - out: str, - provenance: str, - src: str, -) -> None: - src_uri: str = ( - # GitLab - ( - f"git+{environ['CI_PROJECT_URL']}" - if "CI_PROJECT_URL" in environ - else "" - ) - # GitHub - or ( - f"git+https://{environ['GITHUB_SERVER_URL']}" - f"/{environ['GITHUB_REPOSITORY']}" - if "GITHUB_SERVER_URL" in environ - and "GITHUB_REPOSITORY" in environ - else "" - ) - # Local - or (f"git+file://{abspath(src)}" if abspath(src) == CWD else "") - # Other - or src - ) - - CON.rule("Provenance") - attestation: Dict[str, Any] = {} - attestation["_type"] = "https://in-toto.io/Statement/v0.1" - attestation["predicateType"] = "https://slsa.dev/provenance/v0.2" - - attestation["predicate"] = {} - attestation["predicate"]["builder"] = {} - attestation["predicate"]["builder"]["id"] = f"{_get_usr()}@{_get_sys_id()}" - attestation["predicate"]["buildType"] = ( - f"https://fluidattacks.com/Attestations/Makes@{VERSION}", - ) - attestation["predicate"]["invocation"] = {} - attestation["predicate"]["invocation"]["configSource"] = { - "uri": src_uri, - "digest": {"sha1": _clone_src_git_rev_parse(head, "HEAD")}, - "entrypoint": args[2], - } - attestation["predicate"]["invocation"]["parameters"] = args[3:] - attestation["predicate"]["invocation"]["environment"] = { - key: "" for key in environ - } - attestation["predicate"]["metadata"] = {} - attestation["predicate"]["metadata"]["completeness"] = {} - attestation["predicate"]["metadata"]["completeness"]["environment"] = True - attestation["predicate"]["metadata"]["completeness"]["materials"] = True - attestation["predicate"]["metadata"]["completeness"]["parameters"] = True - attestation["predicate"]["metadata"]["reproducible"] = True - attestation["predicate"]["materials"] = [ - { - "uri": requisite, - "hash": dict([hash_.split(":")]), - } - for requisite, hash_ in _nix_build_requisites(out) - ] - - attestation["subject"] = [ - { - "uri": realpath(out), - "hash": dict([_nix_hashes(out)[0].split(":")]), - } - ] - - attestation_bytes = json.dumps( - attestation, - indent=2, - sort_keys=True, - ).encode() - - with open(provenance, mode="wb+") as attestation_file: - attestation_file.write(attestation_bytes) - - integrity = sha256(attestation_bytes).hexdigest() - - CON.out(f"Attestation: {provenance}") - CON.out(f"SHA-256: {integrity}") - - -def main() -> None: - try: - cli(sys.argv) - except SystemExit as err: - CON.out() - if err.code == 0: - emo = random.choice(EMOJIS_SUCCESS) # nosec - CON.rule(f":{emo}: Success!") - else: - emo = random.choice(EMOJIS_FAILURE) # nosec - CON.rule(f":{emo}: Failed with exit code {err.code}", style="red") - CON.out() - - sys.exit(err.code) - - -def cleanup() -> None: - for action in ON_EXIT: - with suppress(BaseException): - action() - if __name__ == "__main__": - try: - main() - finally: - cleanup() + main(sys.argv) diff --git a/src/cli/main/cli.py b/src/cli/main/cli.py new file mode 100644 index 00000000..74e1461e --- /dev/null +++ b/src/cli/main/cli.py @@ -0,0 +1,792 @@ +# SPDX-FileCopyrightText: 2022 Fluid Attacks and Makes contributors +# +# SPDX-License-Identifier: MIT + +from contextlib import ( + suppress, +) +import emojis +from functools import ( + partial, +) +from hashlib import ( + sha256, +) +import io +import json +import operator +import os +from os import ( + environ, + getcwd, + getlogin, + makedirs, + remove, +) +from os.path import ( + exists, + getctime, + join, + realpath, +) +from posixpath import ( + abspath, + dirname, +) +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 +from socket import ( + gethostname, +) +import subprocess # nosec +import sys +import tempfile +import textwrap +from time import ( + time, +) +from tui import ( + TextUserInterface, +) +from typing import ( + Any, + Callable, + Dict, + List, + NamedTuple, + Optional, + Set, + Tuple, +) +from urllib.parse import ( + quote_plus as url_quote, +) +import warnings + +CWD: str = getcwd() +CON: rich.console.Console = rich.console.Console( + highlight=False, + 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 = "22.10" + +# Environment +__MAKES_SRC__: str = environ["__MAKES_SRC__"] +__NIX_STABLE__: str = environ["__NIX_STABLE__"] +__NIX_UNSTABLE__: str = environ["__NIX_UNSTABLE__"] + + +# Feature flags +AWS_BATCH_COMPAT: bool = bool(environ.get("MAKES_AWS_BATCH_COMPAT")) +if AWS_BATCH_COMPAT: + CON.out("Using feature flag: MAKES_AWS_BATCH_COMPAT") + CON.out() + +GIT_DEPTH: int = int(environ.get("MAKES_GIT_DEPTH", "1")) +if GIT_DEPTH != 1: + CON.out(f"Using feature flag: MAKES_GIT_DEPTH={GIT_DEPTH}") + + +K8S_COMPAT: bool = bool(environ.get("MAKES_K8S_COMPAT")) +if K8S_COMPAT: + CON.out("Using feature flag: MAKES_K8S_COMPAT") + +NIX_STABLE: bool = not bool(environ.get("MAKES_NIX_UNSTABLE")) +if not NIX_STABLE: + CON.out("Using feature flag: MAKES_NIX_UNSTABLE") + + +# Constants + + +def _if(condition: Any, *value: Any) -> List[Any]: + return list(value) if condition else [] + + +def _clone_src(src: str) -> str: + # pylint: disable=consider-using-with + 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 .` ? + if NIX_STABLE: + _add_safe_directory() + _clone_src_git_worktree_add(src, head) + else: + # Nix with Flakes already ensures a pristine git repo + head = src + else: + _add_safe_directory() + if ( + (match := _clone_src_github(src)) + or (match := _clone_src_gitlab(src)) + or (match := _clone_src_local(src)) + ): + cache_key, remote, rev = match + else: + CON.print(f"We can't proceed with SOURCE: {src}", justify="center") + CON.print("It has an unrecognized format", justify="center") + CON.print() + CON.print("Please see the correct usage below", justify="center") + _help_and_exit_base() + + _clone_src_git_init(head) + remote = _clone_src_cache_get(src, cache_key, remote) + _clone_src_git_fetch(head, remote, rev) + _clone_src_git_checkout(head, rev) + _clone_src_cache_refresh(head, cache_key) + + return head + + +def _add_safe_directory() -> None: + cmd = [ + "git", + "config", + "--global", + "--add", + "safe.directory", + "/github/workspace", + ] + out = _run(cmd, stderr=None, stdout=sys.stderr.fileno()) + if out != 0: + raise SystemExit(out) + + +def _clone_src_git_init(head: str) -> None: + cmd = ["git", "init", "--initial-branch=____", "--shared=false", head] + out = _run(cmd, stderr=None, stdout=sys.stderr.fileno()) + if out != 0: + raise SystemExit(out) + + +def _clone_src_git_rev_parse(head: str, rev: str) -> str: + cmd = ["git", "-C", head, "rev-parse", rev] + out, stdout, _ = _run_outputs(cmd, stderr=None) + if out != 0: + raise SystemExit(out) + + return next(iter(stdout.decode().splitlines()), "HEAD") + + +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=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=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=sys.stderr.fileno()) + if out != 0: + raise SystemExit(out) + CON.out(head) + + +def _clone_src_github(src: str) -> Optional[Tuple[str, str, str]]: + regex = r"^github:(?P.*)/(?P.*)@(?P.*)$" + + if match := re.match(regex, src): + owner = url_quote(match.group("owner")) + repo = url_quote(match.group("repo")) + rev = url_quote(match.group("rev")) + remote = f"https://github.com/{owner}/{repo}" + cache_key = f"github-{owner}-{repo}-{rev}" + + return cache_key, remote, rev + + return None + + +def _clone_src_gitlab(src: str) -> Optional[Tuple[str, str, str]]: + regex = r"^gitlab:(?P.*)/(?P.*)@(?P.*)$" + + if match := re.match(regex, src): + owner = url_quote(match.group("owner")) + repo = url_quote(match.group("repo")) + rev = url_quote(match.group("rev")) + remote = f"https://gitlab.com/{owner}/{repo}.git" + cache_key = f"gitlab-{owner}-{repo}-{rev}" + + return cache_key, remote, rev + + return None + + +def _clone_src_local(src: str) -> Optional[Tuple[str, str, str]]: + regex = r"^local:(?P.*)@(?P.*)$" + + if match := re.match(regex, src): + path = url_quote(match.group("path")) + rev = url_quote(match.group("rev")) + remote = f"file://{path}" + cache_key = "" + + return cache_key, remote, rev + + return None + + +def _clone_src_cache_get(src: str, cache_key: str, remote: str) -> str: + cached: str = join(SOURCES_CACHE, cache_key) + if cache_key: + if exists(cached): + cached_since: float = time() - getctime(cached) + if cached_since <= 86400.0: + CON.out(f"Cached from {cached}") + remote = cached + else: + shutil.rmtree(cached) + else: + CON.out(f"From {src}") + + return remote + + +def _clone_src_cache_refresh(head: str, cache_key: str) -> None: + cached: str = join(SOURCES_CACHE, cache_key) + if cache_key and not exists(cached): + shutil.copytree(head, cached) + + +def _nix_build( + *, + attr: str, + cache: Optional[List[Dict[str, str]]], + head: str, + out: str = "", +) -> List[str]: + if cache is None: + substituters = "https://cache.nixos.org" + trusted_pub_keys = ( + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + ) + else: + substituters = " ".join(map(operator.itemgetter("url"), cache)) + trusted_pub_keys = " ".join(map(operator.itemgetter("pubKey"), cache)) + + return [ + *_if(NIX_STABLE, f"{__NIX_STABLE__}/bin/nix-build"), + *_if(not NIX_STABLE, f"{__NIX_UNSTABLE__}/bin/nix"), + *_if(not NIX_STABLE, "--experimental-features", "flakes nix-command"), + *_if(not NIX_STABLE, "build"), + *_if(NIX_STABLE, "--argstr", "makesSrc", __MAKES_SRC__), + *_if(NIX_STABLE, "--argstr", "projectSrc", head), + *_if(NIX_STABLE, "--attr", attr), + *["--option", "cores", "0"], + *_if(not NIX_STABLE, "--impure"), + *["--option", "narinfo-cache-negative-ttl", "1"], + *["--option", "narinfo-cache-positive-ttl", "1"], + *["--option", "max-jobs", "auto"], + *["--option", "substituters", substituters], + *["--option", "trusted-public-keys", trusted_pub_keys], + *["--option", "sandbox", "false" if K8S_COMPAT else "true"], + *_if(out, "--out-link", out), + *_if(not out, "--no-out-link"), + *["--show-trace"], + *_if(NIX_STABLE, f"{__MAKES_SRC__}/src/evaluator/default.nix"), + *_if(not NIX_STABLE, attr), + ] + + +def _nix_hashes(*paths: str) -> List[str]: + cmd = [ + f"{__NIX_STABLE__}/bin/nix-store", + "--query", + "--hash", + *paths, + ] + out, stdout, _ = _run_outputs(cmd, stderr=None) + if out != 0: + raise SystemExit(out) + + return stdout.decode().splitlines() + + +def _nix_build_requisites(path: str) -> List[Tuple[str, str]]: + """Answer the question: what do I need to build `out`.""" + cmd = [f"{__NIX_STABLE__}/bin/nix-store", "--query", "--deriver", path] + out, stdout, _ = _run_outputs(cmd, stderr=None) + if out != 0: + raise SystemExit(out) + + cmd = [ + f"{__NIX_STABLE__}/bin/nix-store", + "--query", + "--requisites", + "--include-outputs", + *stdout.decode().splitlines(), + ] + out, stdout, _ = _run_outputs(cmd, stderr=None) + if out != 0: + raise SystemExit(out) + + requisites: List[str] = stdout.decode().splitlines() + + hashes: List[str] = _nix_hashes(*requisites) + + return list(zip(requisites, hashes)) + + +def _get_head(src: str) -> str: + # Checkout repository HEAD into a temporary directory + # This is nice for reproducibility and security, + # files not in the HEAD commit are left out of the build inputs + CON.out() + CON.rule(f"Fetching {src}") + CON.out() + head: str = _clone_src(src) + + # Applies only to local repositories on non-flakes Nix + if abspath(src) == CWD and NIX_STABLE: # `m .` ? + paths: Set[str] = set() + + # Propagated `git add`ed files + cmd = ["git", "-C", src, "diff", "--cached", "--name-only"] + out, stdout, _ = _run_outputs(cmd, stderr=None) + if out != 0: + raise SystemExit(out) + paths.update(stdout.decode().splitlines()) + + # Propagated modified files + cmd = ["git", "-C", src, "ls-files", "--modified"] + out, stdout, _ = _run_outputs(cmd, stderr=None) + if out != 0: + raise SystemExit(out) + paths.update(stdout.decode().splitlines()) + + # Copy paths to head + for path in sorted(paths): + dest = join(head, path) + path = join(src, path) + if not exists(dirname(dest)): + makedirs(dirname(dest)) + if exists(path): + shutil.copy(path, dest) + else: + remove(dest) + + return head + + +class Config(NamedTuple): + attrs: List[str] + cache: List[Dict[str, str]] + + +def _get_config(head: str) -> Config: + CON.out() + CON.rule("Building project configuration") + CON.out() + out: str = tempfile.mktemp() # nosec + code = _run( + args=_nix_build( + attr="config.configAsJson" + if NIX_STABLE + else f'{head}#__makes__."config:configAsJson"', + cache=None, + head=head, + out=out, + ), + env=None if NIX_STABLE else dict(HOME=environ["HOME_IMPURE"]), + stderr=None, + stdout=sys.stderr.fileno(), + ) + + if code == 0: + with open(out, encoding="utf-8") as file: + config: Dict[str, Any] = json.load(file) + + return Config(attrs=config["outputs"], cache=config["cache"]) + + raise SystemExit(code) + + +def _run_outputs( # pylint: disable=too-many-arguments + args: List[str], + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + stdout: Optional[int] = subprocess.PIPE, + stderr: Optional[int] = subprocess.PIPE, + stdin: Optional[bytes] = None, +) -> Tuple[int, bytes, bytes]: + env = environ | (env or {}) + + with subprocess.Popen( + args=args, + cwd=cwd, + env=env, + shell=False, # nosec + stdin=None if stdin is None else subprocess.PIPE, + stdout=stdout, + stderr=stderr, + ) as process: + out, err = process.communicate(stdin) + + return process.returncode, out, err + + +def _run( # pylint: disable=too-many-arguments + args: List[str], + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + stdout: Optional[int] = None, + stderr: Optional[int] = None, + stdin: Optional[bytes] = None, +) -> int: + env = environ | (env or {}) + + with subprocess.Popen( + args=args, + cwd=cwd, + env=env, + shell=False, # nosec + stdin=None if stdin is None else subprocess.PIPE, + stdout=stdout, + stderr=stderr, + ) as process: + return process.wait() + + +def _help_and_exit_base() -> None: + CON.out() + CON.rule("Usage") + CON.out() + + text = "$ m SOURCE" + CON.print(rich.panel.Panel.fit(text), justify="center") + CON.out() + + text = """ + Can be: + + A git repository in the current working directory: + $ m . + + A git repository and revision: + $ m local:/path/to/repo@rev + + A GitHub repository and revision: + $ m github: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() + + 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)." + CON.print(rich.panel.Panel(text, title="ARGS")) + + raise SystemExit(1) + + +def _help_picking_attr(src: str, attrs: List[str]) -> List[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() + + state: Dict[str, Any] = {} + TextUserInterface.run( + attrs=attrs, initial_input=initial_input, state=state, src=src + ) + + if "return" in state: + with open(cache, encoding="utf-8", mode="w") as file: + file.write(shlex.join(state["return"])) + return state["return"] + + 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") + CON.print(f"v{VERSION}-{sys.platform}", justify="center") + if args[1:]: + src: str = args[1] + else: + _help_and_exit_base() + + head: str = _get_head(src) + config: Config = _get_config(head) + + args, attr = _cli_get_args_and_attr(args, config.attrs, src) + + out: str = join(MAKES_DIR, f"out{attr.replace('/', '-')}") + provenance: str = join( + MAKES_DIR, + f"provenance{attr.replace('/', '-')}.json", + ) + code = _cli_build(attr, config, head, out, src) + + if code == 0: + write_provenance(args, head, out, provenance, src) + cache_push(config.cache, out) + execute_action(args[3:], head, out) + + raise SystemExit(code) + + +def _cli_get_args_and_attr( + args: List[str], + attrs: List[str], + src: str, +) -> Tuple[List[str], str]: + if args[2:]: + attr: str = args[2] + elif CON.is_terminal: + args = [*args[0:2], *_help_picking_attr(src, attrs)] + attr = args[2] + else: + _help_and_exit_with_src_no_tty(src, attrs) + + return args, attr + + +def _cli_build( + attr: str, + config: Config, + head: str, + out: str, + src: str, +) -> int: + CON.out() + CON.rule(f"Building {attr}") + CON.out() + if attr not in config.attrs: + CON.print(f"We can't proceed with OUTPUT: {attr}", justify="center") + 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_with_src_no_tty(src, config.attrs) + + code = _run( + args=_nix_build( + attr=f'config.outputs."{attr}"' + if NIX_STABLE + else f'{head}#__makes__."config:outputs:{attr}"', + cache=config.cache, + head=head, + out=out, + ), + env=None if NIX_STABLE else dict(HOME=environ["HOME_IMPURE"]), + stderr=None, + stdout=None, + ) + + return code + + +def execute_action(args: List[str], head: str, out: str) -> None: + action_path: str = join(out, "makes-action.sh") + + if exists(action_path): + CON.out() + CON.rule("Running") + CON.out() + code = _run( + args=[action_path, out, *args], + stderr=None, + stdout=None, + cwd=head if AWS_BATCH_COMPAT else CWD, + ) + raise SystemExit(code) + + +def cache_push(cache: List[Dict[str, str]], out: str) -> None: + once: bool = True + for config in cache: + if config["type"] == "cachix" and "CACHIX_AUTH_TOKEN" in environ: + if once: + CON.rule("Pushing to cache") + once = False + _run( + args=["cachix", "push", "-c", "0", config["name"], out], + stderr=None, + stdout=sys.stderr.fileno(), + ) + return + + +def _get_sys_id() -> str: + with suppress(AttributeError): + uname = os.uname() + return f"{uname.nodename}-{uname.sysname}-{uname.machine}" + + with suppress(OSError): + return gethostname() + + return "unknown" + + +def _get_usr() -> str: + with suppress(OSError): + return getlogin() + + return "unknown" + + +def write_provenance( + args: List[str], + head: str, + out: str, + provenance: str, + src: str, +) -> None: + src_uri: str = ( + # GitLab + ( + f"git+{environ['CI_PROJECT_URL']}" + if "CI_PROJECT_URL" in environ + else "" + ) + # GitHub + or ( + f"git+https://{environ['GITHUB_SERVER_URL']}" + f"/{environ['GITHUB_REPOSITORY']}" + if "GITHUB_SERVER_URL" in environ + and "GITHUB_REPOSITORY" in environ + else "" + ) + # Local + or (f"git+file://{abspath(src)}" if abspath(src) == CWD else "") + # Other + or src + ) + + CON.rule("Provenance") + attestation: Dict[str, Any] = {} + attestation["_type"] = "https://in-toto.io/Statement/v0.1" + attestation["predicateType"] = "https://slsa.dev/provenance/v0.2" + + attestation["predicate"] = {} + attestation["predicate"]["builder"] = {} + attestation["predicate"]["builder"]["id"] = f"{_get_usr()}@{_get_sys_id()}" + attestation["predicate"]["buildType"] = ( + f"https://fluidattacks.com/Attestations/Makes@{VERSION}", + ) + attestation["predicate"]["invocation"] = {} + attestation["predicate"]["invocation"]["configSource"] = { + "uri": src_uri, + "digest": {"sha1": _clone_src_git_rev_parse(head, "HEAD")}, + "entrypoint": args[2], + } + attestation["predicate"]["invocation"]["parameters"] = args[3:] + attestation["predicate"]["invocation"]["environment"] = { + key: "" for key in environ + } + attestation["predicate"]["metadata"] = {} + attestation["predicate"]["metadata"]["completeness"] = {} + attestation["predicate"]["metadata"]["completeness"]["environment"] = True + attestation["predicate"]["metadata"]["completeness"]["materials"] = True + attestation["predicate"]["metadata"]["completeness"]["parameters"] = True + attestation["predicate"]["metadata"]["reproducible"] = True + attestation["predicate"]["materials"] = [ + { + "uri": requisite, + "hash": dict([hash_.split(":")]), + } + for requisite, hash_ in _nix_build_requisites(out) + ] + + attestation["subject"] = [ + { + "uri": realpath(out), + "hash": dict([_nix_hashes(out)[0].split(":")]), + } + ] + + attestation_bytes = json.dumps( + attestation, + indent=2, + sort_keys=True, + ).encode() + + with open(provenance, mode="wb+") as attestation_file: + attestation_file.write(attestation_bytes) + + integrity = sha256(attestation_bytes).hexdigest() + + CON.out(f"Attestation: {provenance}") + CON.out(f"SHA-256: {integrity}") + + +def main(args: List[str]) -> None: + try: + try: + cli(args) + except SystemExit as err: + CON.out() + if err.code == 0: + emo = random.choice(emojis.SUCCESS) # nosec + CON.rule(f":{emo}: Success!") + else: + emo = random.choice(emojis.FAILURE) # nosec + CON.rule( + f":{emo}: Failed with exit code {err.code}", style="red" + ) + CON.out() + sys.exit(err.code) + finally: + cleanup() + + +def cleanup() -> None: + for action in ON_EXIT: + with suppress(BaseException): + action() diff --git a/src/cli/main/emojis.py b/src/cli/main/emojis.py new file mode 100644 index 00000000..4413d60f --- /dev/null +++ b/src/cli/main/emojis.py @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: 2022 Fluid Attacks and Makes contributors +# +# SPDX-License-Identifier: MIT + +from typing import ( + List, +) + +FAILURE: List[str] = [ + "alien_monster", # 👾 + "anxious_face_with_sweat", # 😰 + "beetle", # 🐞 + "blowfish", # 🐡 + "brick", # 🧱 + "broken_heart", # 💔 + "bug", # 🐛 + "collision", # 💥 + "dizzy_face", # 😵 + "exploding_head", # 🤯 + "eyes", # 👀 + "face_with_monocle", # 🧐 + "fire", # 🔥 + "ghost", # 👻 + "lady_beetle", # 🐞 + "mega", # 📣 + "microscope", # 🔬 + "moai", # 🗿 + "open_mouth", # 😮 + "person_facepalming", # 🤦 + "person_getting_massage", # 💆 + "sad_but_relieved_face", # 😥 + "see_no_evil", # 🙈 + "smiling_imp", # 😈 + "speak_no_evil", # 🙊 + "thinking_face", # 🤔 + "upside__down_face", # 🙃 + "volcano", # 🌋 + "wilted_flower", # 🥀 + "woozy_face", # 🥴 + "yawning_face", # 🥱 + "zipper__mouth_face", # 🤐 +] +SUCCESS: List[str] = [ + "airplane_departure", # 🛫 + "beer", # 🍺 + "beers", # 🍻 + "birthday", # 🎂 + "bottle_with_popping_cork", # 🍾 + "bouquet", # 💐 + "bulb", # 💡 + "blossom", # 🌼 + "boxing_glove", # 🥊 + "call_me_hand", # 🤙 + "cat", # 🐱 + "clapping_hands", # 👏 + "clinking_glasses", # 🥂 + "colombia", # 🇨🇴 + "confetti_ball", # 🎊 + "couple_with_heart", # 💑 + "checkered_flag", # 🏁 + "crown", # 👑 + "dart", # 🎯 + "dog", # 🐶 + "dancer", # 💃 + "doughnut", # 🍩 + "eagle", # 🦅 + "elephant", # 🐘 + "face_blowing_a_kiss", # 😘 + "flamingo", # 🦩 + "four_leaf_clover", # 🍀 + "fries", # 🍟 + "glowing_star", # 🌟 + "kite", # 🪁 + "mage", # 🧙 + "merperson", # 🧜 + "money_with_wings", # 💸 + "nail_care", # 💅 + "party_popper", # 🎉 + "partying_face", # 🥳 + "person_cartwheeling", # 🤸 + "person_playing_handball", # 🤾 + "person_playing_water_polo", # 🤽 + "person_surfing", # 🏄 + "pizza", # 🍕 + "popcorn", # 🍿 + "rainbow", # 🌈 + "shooting_star", # 🌠 + "smiling_face_with_sunglasses", # 😎 + "smirk", # 😏 + "rocket", # 🚀 + "trophy", # 🏆 + "whale", # 🐳 + "wink", # 😉 +] diff --git a/src/cli/main/tui.py b/src/cli/main/tui.py new file mode 100644 index 00000000..83ca2964 --- /dev/null +++ b/src/cli/main/tui.py @@ -0,0 +1,204 @@ +# SPDX-FileCopyrightText: 2022 Fluid Attacks and Makes contributors +# +# SPDX-License-Identifier: MIT + +from os.path import ( + commonprefix, +) +import rich.align +import rich.console +import rich.markup +import rich.panel +import rich.table +import rich.text +import shlex +import textual.app +import textual.events +import textual.keys +import textual.reactive +import textual.widget +import textual.widgets +from typing import ( + Any, + Dict, + List, +) + + +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): + output = textual.reactive.Reactive("") + outputs = textual.reactive.Reactive([]) + + def render(self) -> rich.text.Text: + if self.outputs: + longest = max(map(len, self.outputs)) + text = rich.text.Text() + for output in self.outputs: + text.append(self.output, style="yellow") + text.append(output[len(self.output) :]) + text.append(" " * (longest - len(output))) + text.append("\n") + else: + text = rich.text.Text("(none)") + return rich.align.Align(text, align="center") # type: ignore + + +class TuiOutputsTitle(textual.widget.Widget): + output = textual.reactive.Reactive("") + + def render(self) -> rich.text.Text: + text = rich.text.Text("Outputs starting with: ", justify="center") + text.append(self.output, style="yellow") + return text + + +class TextUserInterface(textual.app.App): + # pylint: disable=too-many-instance-attributes + def __init__( + self, + *args: Any, + src: str, + attrs: List[str], + initial_input: str, + state: Dict[str, Any], + **kwargs: Any, + ) -> None: + self.attrs = attrs + self.src = src + self.state = state + + 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(): + self.state["return"] = [self.output, *self.args] + await self.action_quit() + else: + self.input += event.key + self.propagate_data(autocomplete=True) + await self.outputs_scroll.update(self.outputs) # type: ignore + + 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.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, + ) diff --git a/src/cli/test_all.py b/src/cli/test_all.py new file mode 100644 index 00000000..83eeb02a --- /dev/null +++ b/src/cli/test_all.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2022 Fluid Attacks and Makes contributors +# +# SPDX-License-Identifier: MIT + +from main import ( + cli, + emojis, + tui, +) +import os +import pytest +import shutil + + +def test_imports() -> None: + assert emojis + assert tui + + +def test_tui() -> None: + assert tui.TuiCommand(src=".").render() + assert tui.TuiUsage(src=".").render() + assert tui.TuiHeader().render() + tui_outputs = tui.TuiOutputs() + tui_outputs.outputs = ["/a", "/b"] + assert tui_outputs.render() + assert tui.TuiOutputsTitle().render() + + +def test_help() -> None: + with pytest.raises(SystemExit) as result: + cli.main(["m"]) + assert result.value.code == 1 + + with pytest.raises(SystemExit) as result: + cli.main(["m", "--help"]) + assert result.value.code == 1 + + +def test_dot_wrong() -> None: + with pytest.raises(SystemExit) as result: + cli.main(["m", ".", "/shouldn't/exist"]) + assert result.value.code == 1 + + +def test_dot_hello_world() -> None: + with pytest.raises(SystemExit) as result: + cli.main(["m", ".", "/helloWorld"]) + assert result.value.code == 0 + + +def test_remote_hello_world() -> None: + cache = ( + os.environ["HOME_IMPURE"] + + "/.makes/cache/sources/github-fluidattacks-makes-main" + ) + + shutil.rmtree(cache, ignore_errors=True) + + with pytest.raises(SystemExit) as result: + cli.main(["m", "github:fluidattacks/makes@main", "/helloWorld"]) + assert result.value.code == 0 + + with pytest.raises(SystemExit) as result: + cli.main(["m", "github:fluidattacks/makes@main", "/helloWorld"]) + assert result.value.code == 0 + + with pytest.raises(SystemExit) as result: + cli.main(["m", f"local:{cache}@main", "/helloWorld"]) + assert result.value.code == 0 + + with pytest.raises(SystemExit) as result: + cli.main(["m", f"local:{cache}@shouldn't-exist", "/helloWorld"]) + assert result.value.code == 128 + + with pytest.raises(SystemExit) as result: + cli.main(["m", "gitlab:fluidattacks/shouldn't@exist", "/helloWorld"]) + assert result.value.code == 128