diff --git a/.gitignore b/.gitignore index ec655338..4efe0033 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,9 @@ gha-creds-*.json ### jEnv .java-version + +### Python CLI ### +/teaspoons-cli/teaspoons/__pycache__/ +/teaspoons-cli/teaspoons/**/__pycache__/ +/teaspoons-cli/venv/ +/teaspoons-cli/teaspoons/generated/ diff --git a/common/openapi.yml b/common/openapi.yml index 75931742..40968976 100644 --- a/common/openapi.yml +++ b/common/openapi.yml @@ -543,12 +543,14 @@ components: $ref: '#/components/schemas/JobReport' GetPipelinesResult: - type: array - items: - $ref: '#/components/schemas/Pipeline' + description: result of a getPipelines request + type: object properties: - Pipeline: - $ref: '#/components/schemas/Pipeline' + results: + description: List of retrieved pipelines + type: array + items: + $ref: '#/components/schemas/Pipeline' GetPipelineRunsResponse: type: object diff --git a/service/src/main/java/bio/terra/pipelines/app/controller/PipelinesApiController.java b/service/src/main/java/bio/terra/pipelines/app/controller/PipelinesApiController.java index 9772e4c0..17060d9e 100644 --- a/service/src/main/java/bio/terra/pipelines/app/controller/PipelinesApiController.java +++ b/service/src/main/java/bio/terra/pipelines/app/controller/PipelinesApiController.java @@ -77,9 +77,7 @@ public ResponseEntity getPipelineDetails( static ApiGetPipelinesResult pipelinesToApi(List pipelineList) { ApiGetPipelinesResult apiResult = new ApiGetPipelinesResult(); - for (Pipeline pipeline : pipelineList) { - apiResult.add(pipelineToApi(pipeline)); - } + apiResult.setResults(pipelineList.stream().map(PipelinesApiController::pipelineToApi).toList()); return apiResult; } diff --git a/service/src/test/java/bio/terra/pipelines/controller/PipelinesApiControllerTest.java b/service/src/test/java/bio/terra/pipelines/controller/PipelinesApiControllerTest.java index 184e9be2..b6b11934 100644 --- a/service/src/test/java/bio/terra/pipelines/controller/PipelinesApiControllerTest.java +++ b/service/src/test/java/bio/terra/pipelines/controller/PipelinesApiControllerTest.java @@ -76,7 +76,7 @@ void getPipelinesOk() throws Exception { new ObjectMapper() .readValue(result.getResponse().getContentAsString(), ApiGetPipelinesResult.class); - assertEquals(testPipelineList.size(), response.size()); + assertEquals(testPipelineList.size(), response.getResults().size()); } @Test diff --git a/teaspoons-cli/.env b/teaspoons-cli/.env new file mode 100644 index 00000000..8630782d --- /dev/null +++ b/teaspoons-cli/.env @@ -0,0 +1,13 @@ +# Teaspoons API URL +TEASPOONS_API_URL=https://tsps.dsde-dev.broadinstitute.org + +# Port to use for local server (for auth) +SERVER_PORT=10444 + +# Oauth config stuff +OAUTH_OPENID_CONFIGURATION_URI=https://terradevb2c.b2clogin.com/terradevb2c.onmicrosoft.com/b2c_1a_signup_signin_dev/v2.0/.well-known/openid-configuration +OAUTH_CLIENT_ID=bbd07d43-01cb-4b69-8fd0-5746d9a5c9fe + +# Teaspoons storage (absolute path or relative to user's home directory) +LOCAL_STORAGE_PATH=.teaspoons + diff --git a/teaspoons-cli/LICENSE b/teaspoons-cli/LICENSE new file mode 100644 index 00000000..38843994 --- /dev/null +++ b/teaspoons-cli/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2024, Broad Institute +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/teaspoons-cli/README.md b/teaspoons-cli/README.md index 973311f6..999ccbaa 100644 --- a/teaspoons-cli/README.md +++ b/teaspoons-cli/README.md @@ -1,6 +1,72 @@ # Teaspoons CLI +## Python CLI structure +The CLI code is structured as follows: +``` +teaspoons-cli +├── teaspoons +│ └── commands +│ │ └── __init__.py +│ │ └── auth.py +│ │ └── pipelines.py +│ └── generated +│ │ └── [auto-generated files for thin client] +├── └── tests (to be created) +│ └── __init__.py +│ └── auth_helper.py +│ └── cli.py +│ └── config.py +│ └── teaspoons +├── pyproject.toml +├── poetry.lock +├── README.md +``` + +Inside the `teaspoons` directory, we have the following files: +- `teaspoons` is the entrypoint for the CLI. It contains the main function that is called when the CLI is run. +- `auth.py` contains the code for authenticating with the Teaspoons service (Terra, via b2c). +- `config.py` contains the code for managing the CLI configuration via environment variables. +- `cli.py` assembles the CLI sub-modules that are defined in `commands/`. +- A future file will be included to contain the business logic for the CLI commands. +- The `commands` directory contains the CLI sub-modules. This is effectively the controller layer for the CLI. +- The `generated` directory contains the auto-generated files for the thin client, containing the python model classes and API calls. + + +## Using the CLI +For now, the CLI requires poetry to be installed to run. See the [Development](#development) section for instructions on how to install poetry. + +To run the CLI, navigate to the `teaspoons-cli/teaspoons/` directory and run the following command: +```bash +./teaspoons COMMAND [ARGS] +``` + +For example, to authenticate with the Teaspoons service, run the following command: +```bash +./teaspoons auth login +``` + +To list the pipelines in the Teaspoons service, run the following command: +```bash +./teaspoons pipelines list +``` + +See WIP documentation for the CLI [here](https://docs.google.com/document/d/1ovbcHCzdyuC8RjFfkVJZiuDTQ_UAVrglSxSGaZwppoY/edit?tab=t.0#heading=h.jfsr3j3x0zjr). + + +## Development +You'll need to have poetry installed to manage python dependencies. Instructions for installing poetry can be found [here](https://python-poetry.org/docs/). + + +## Python thick client auto-generation +To generate the Python thick client (which will also generate the thin client), run the following command: +```bash +./gradlew :teaspoons-cli:cliBuild +``` + + ## Python thin client auto-generation +Note: If you've already built the thick client, you don't need to generate the thin client separately. + We use the [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator) to generate the "thin" Python client, which is then used to build the Python-based "thick" CLI tool. @@ -9,7 +75,8 @@ To generate the Python thin client, run the following command: ./gradlew :teaspoons-cli:openApiGenerate ``` -This will produce generated files at `/teaspoons-cli/build/`. +This will produce generated files at `/teaspoons-cli/teaspoons/generated/`. Note we do not run the openApiGenerate task as part of the main Teaspoons build, as it is not necessary for the service itself and we don't want any potential bugs in the CLI to affect the service. + diff --git a/teaspoons-cli/poetry.lock b/teaspoons-cli/poetry.lock new file mode 100644 index 00000000..8b033502 --- /dev/null +++ b/teaspoons-cli/poetry.lock @@ -0,0 +1,301 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "oauth2-cli-auth" +version = "1.5.0" +description = "Authenticate against OAuth2 Provider in Python CLIs" +optional = false +python-versions = ">=3.9" +files = [ + {file = "oauth2_cli_auth-1.5.0-py3-none-any.whl", hash = "sha256:60d017fe682c8ab0a9b9e79312ec51a7faf0082e160c8660ae91a8b85696ad16"}, + {file = "oauth2_cli_auth-1.5.0.tar.gz", hash = "sha256:2621c67290d57dcbcd99cbce45e5c82b669f942749e1c54306cbdeef2fe4a3d3"}, +] + +[[package]] +name = "pydantic" +version = "2.9.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyjwt" +version = "2.9.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, + {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "teaspoons-client" +version = "0.0.0" +description = "Terra Scientific Pipelines Service" +optional = false +python-versions = "^3.7" +files = [] +develop = false + +[package.dependencies] +pydantic = ">=2" +python-dateutil = ">=2.8.2" +typing-extensions = ">=4.7.1" +urllib3 = ">= 1.25.3" + +[package.source] +type = "directory" +url = "teaspoons/generated" + +[[package]] +name = "typer" +version = "0.9.4" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.6" +files = [ + {file = "typer-0.9.4-py3-none-any.whl", hash = "sha256:aa6c4a4e2329d868b80ecbaf16f807f2b54e192209d7ac9dd42691d63f7a54eb"}, + {file = "typer-0.9.4.tar.gz", hash = "sha256:f714c2d90afae3a7929fcd72a3abb08df305e1ff61719381384211c4070af57f"}, +] + +[package.dependencies] +click = ">=7.1.1,<9.0.0" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] +doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] +test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.971)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "3af56d3bc036bf1677008b4eec9195c6d754c1d7614b1ef0f7c2079ce43a0fb4" diff --git a/teaspoons-cli/pyproject.toml b/teaspoons-cli/pyproject.toml new file mode 100644 index 00000000..7129953f --- /dev/null +++ b/teaspoons-cli/pyproject.toml @@ -0,0 +1,20 @@ +[tool.poetry] +name = "teaspoons-cli" +version = "0.0.1" +description = "Command line interface for interacting with the Terra Scientific Pipelines Service, or Teaspoons." +authors = ["Terra Scientific Services "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.9" +teaspoons-client = {path = "teaspoons/generated"} +python-dotenv = "^1.0.1" +click = "^8.1.7" +oauth2-cli-auth = "^1.5.0" +PyJWT = "^2.9.0" +typer = "^0.9.0" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/teaspoons-cli/teaspoons-client.gradle b/teaspoons-cli/teaspoons-client.gradle index 223e8b73..d409e74c 100644 --- a/teaspoons-cli/teaspoons-client.gradle +++ b/teaspoons-cli/teaspoons-client.gradle @@ -1,18 +1,27 @@ tasks.openApiGenerate { generatorName.set("python") inputSpec.set("$rootDir/common/openapi.yml") - outputDir.set("$rootDir/teaspoons-cli/build") + outputDir.set("$projectDir/teaspoons/generated") configOptions.put("projectName", "teaspoons-client") configOptions.put("packageName", "teaspoons_client") configOptions.put("packageVersion", "0.0.0") configOptions.put("httpUserAgent", "teaspoons-client/0.0.0/python") } +// This task is used to build the CLI. Needs some work +task cliBuild (type:Exec) { + dependsOn tasks.openApiGenerate + + workingDir projectDir + + commandLine 'poetry', 'install' +} + // we do NOT want to build this project when building the main service project, to prevent a bug in the cli code from breaking the main service build //compileJava.dependsOn tasks.openApiGenerate task customCleanUp(type:Delete) { - delete "${rootDir}/teaspoons-cli/build/" + delete "$rootDir/teaspoons-cli/teaspoons/generated" } tasks.clean.dependsOn(tasks.customCleanUp) diff --git a/teaspoons-cli/teaspoons/__init__.py b/teaspoons-cli/teaspoons/__init__.py new file mode 100644 index 00000000..e3c12768 --- /dev/null +++ b/teaspoons-cli/teaspoons/__init__.py @@ -0,0 +1,10 @@ +""" +Teaspoons: A CLI for the Terra Scientific Pipelines Service (Teaspoons). + +This package provides a command-line interface to interact with the Teaspoons service, +built on top of an autogenerated thin client. +""" + +__version__ = "0.0.1" +__author__ = "Terra Scientific Services" +__email__ = "teaspoons-developers@broadinstitute.org" diff --git a/teaspoons-cli/teaspoons/auth_helper.py b/teaspoons-cli/teaspoons/auth_helper.py new file mode 100644 index 00000000..d57123e2 --- /dev/null +++ b/teaspoons-cli/teaspoons/auth_helper.py @@ -0,0 +1,74 @@ +# auth_helper.py + +import jwt +import os +import typing as t + +from oauth2_cli_auth import OAuth2ClientInfo, OAuthCallbackHttpServer, get_auth_url, open_browser, exchange_code_for_access_token + +from config import CliConfig + +cli_config = CliConfig() # initialize the config from environment variables + + +def get_access_token_with_browser_open(client_info: OAuth2ClientInfo, server_port: int = cli_config.server_port) -> str: + """ + Note: this is overridden from the oauth2-cli-auth library to use a custom auth url + + Provides a simplified API to: + + - Spin up the callback server + - Open the browser with the authorization URL + - Wait for the code to arrive + - Get access token from code + + :param client_info: Client Info for Oauth2 Interaction + :param server_port: Port of the local web server to spin up + :return: Access Token + """ + callback_server = OAuthCallbackHttpServer(server_port) + auth_url = get_auth_url(client_info, callback_server.callback_url) + open_browser(f"{auth_url}&prompt=login") + code = callback_server.wait_for_code() + if code is None: + raise ValueError("No code could be obtained from browser callback page") + return exchange_code_for_access_token(client_info, callback_server.callback_url, code) + + +def _validate_token(token: str) -> bool: + try: + # Attempt to read the token to ensure it is valid. If it isn't, the file will be removed and None will be returned. + # Note: to simplify, not worrying about the signature of the token since that will be verified by the backend services + # This is just to ensure the token is not expired + jwt.decode(token, options={"verify_signature": False, "verify_exp": True}) + return True + except Exception as e: + return False + + +def _clear_local_token(token_file: str): + try: + os.remove(token_file) + except FileNotFoundError: + pass + + +def _load_local_token(token_file: str) -> t.Optional[str]: + try: + with open(token_file, 'r') as f: + token = f.read() + if _validate_token(token): + return token + else: + return None + + except FileNotFoundError: + _clear_local_token(token_file) + return None + + +def _save_local_token(token_file: str, token: str): + # Create the containing directory if it doesn't exist + os.makedirs(os.path.dirname(token_file), exist_ok=True) + with open(token_file, 'w') as f: + f.write(token) diff --git a/teaspoons-cli/teaspoons/cli.py b/teaspoons-cli/teaspoons/cli.py new file mode 100644 index 00000000..43da8596 --- /dev/null +++ b/teaspoons-cli/teaspoons/cli.py @@ -0,0 +1,14 @@ +# cli.py + +import typer +from commands.auth import auth_app +from commands.pipelines import pipelines_app + +cli = typer.Typer() +cli.add_typer(auth_app, name="auth", help="Authentication commands") +cli.add_typer(pipelines_app, name="pipelines", help="Pipeline commands") +# will add runs_app later + + +if __name__ == '__main__': + cli() diff --git a/teaspoons-cli/teaspoons/client.py b/teaspoons-cli/teaspoons/client.py new file mode 100644 index 00000000..ab50d40e --- /dev/null +++ b/teaspoons-cli/teaspoons/client.py @@ -0,0 +1,29 @@ +# client.py + +from generated.teaspoons_client import Configuration, ApiClient +from config import CliConfig +from auth_helper import _load_local_token + +cli_config = CliConfig() # initialize the config from environment variables + +def _get_api_client(token: str) -> ApiClient: + api_config = Configuration() + api_config.host = cli_config.config["TEASPOONS_API_URL"] + api_config.access_token = token + return ApiClient(configuration=api_config) + +class ClientWrapper: + """ + Wrapper to ensure that the user is authenticated before running the callback and that provides the low level api client to be used + by subsequent commands + """ + + def __enter__(self): + token = _load_local_token(cli_config.token_file) + if not token: + raise ValueError('Please authenticate first') + else: + return _get_api_client(token) + + def __exit__(self, exc_type, exc_val, exc_tb): + pass diff --git a/teaspoons-cli/__init__.py b/teaspoons-cli/teaspoons/commands/__init__.py similarity index 100% rename from teaspoons-cli/__init__.py rename to teaspoons-cli/teaspoons/commands/__init__.py diff --git a/teaspoons-cli/teaspoons/commands/auth.py b/teaspoons-cli/teaspoons/commands/auth.py new file mode 100644 index 00000000..d109a075 --- /dev/null +++ b/teaspoons-cli/teaspoons/commands/auth.py @@ -0,0 +1,27 @@ +# auth.py + +import typer +import click + +from auth_helper import get_access_token_with_browser_open, _validate_token, _save_local_token, _load_local_token, _clear_local_token +from config import CliConfig + +cli_config = CliConfig() # initialize the config from environment variables + +auth_app = typer.Typer() + + +@auth_app.command() +def login(): + token = _load_local_token(cli_config.token_file) + if token and _validate_token(token): + click.echo('Already authenticated') + return + token = get_access_token_with_browser_open(cli_config.client_info) + _save_local_token(cli_config.token_file, token) + + +@auth_app.command() +def logout(): + _clear_local_token(cli_config.token_file) + click.echo('Logged out') diff --git a/teaspoons-cli/teaspoons/commands/pipelines.py b/teaspoons-cli/teaspoons/commands/pipelines.py new file mode 100644 index 00000000..9dec61a9 --- /dev/null +++ b/teaspoons-cli/teaspoons/commands/pipelines.py @@ -0,0 +1,32 @@ +# pipelines.py + +import click +import typer + +# import generated library +from teaspoons_client import PipelinesApi + +# teaspoons modules +from client import ClientWrapper +from utils import _pretty_print, handle_api_exceptions + +pipelines_app = typer.Typer() + + +@pipelines_app.command() +@handle_api_exceptions +def list(): + with ClientWrapper() as api_client: + pipeline_client = PipelinesApi(api_client=api_client) + pipelines = pipeline_client.get_pipelines() + for pipeline in pipelines.pipelines.results: + click.echo(f'{pipeline.name} - {pipeline.description}') + +@pipelines_app.command() +@click.argument('name') +@handle_api_exceptions +def get_info(name: str): + with ClientWrapper() as api_client: + pipeline_client = PipelinesApi(api_client=api_client) + pipeline = pipeline_client.get_pipeline_details(pipeline_name=name) + _pretty_print(pipeline) diff --git a/teaspoons-cli/teaspoons/config.py b/teaspoons-cli/teaspoons/config.py new file mode 100644 index 00000000..c7905df3 --- /dev/null +++ b/teaspoons-cli/teaspoons/config.py @@ -0,0 +1,22 @@ +# config.py + +from pathlib import Path +from oauth2_cli_auth import OAuth2ClientInfo +from dotenv import dotenv_values + + +class CliConfig: + """A class to hold configuration information for the CLI""" + + def __init__(self): + self.config = dotenv_values("../.env") + + self.client_info = OAuth2ClientInfo.from_oidc_endpoint( + self.config["OAUTH_OPENID_CONFIGURATION_URI"], + client_id=self.config["OAUTH_CLIENT_ID"], + scopes=[f"openid+email+profile+{self.config['OAUTH_CLIENT_ID']}"] + ) + + self.server_port = int(self.config["SERVER_PORT"]) + + self.token_file = f'{Path.home()}/{self.config["LOCAL_STORAGE_PATH"]}/access_token' diff --git a/teaspoons-cli/teaspoons/teaspoons b/teaspoons-cli/teaspoons/teaspoons new file mode 100755 index 00000000..37f625bb --- /dev/null +++ b/teaspoons-cli/teaspoons/teaspoons @@ -0,0 +1,6 @@ +#!/usr/bin/env poetry run python + +from cli import cli + +if __name__ == '__main__': + cli() diff --git a/teaspoons-cli/teaspoons/utils.py b/teaspoons-cli/teaspoons/utils.py new file mode 100644 index 00000000..cec50dfb --- /dev/null +++ b/teaspoons-cli/teaspoons/utils.py @@ -0,0 +1,31 @@ +# utils.py + +import click +import json + +from functools import wraps +from pydantic import BaseModel + +from teaspoons_client.exceptions import ApiException + + +def _pretty_print(obj: BaseModel): + """ + Prints a pydantic model in a pretty format to the console + """ + click.echo(json.dumps(obj.model_dump(), indent=4)) + + +def handle_api_exceptions(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except ApiException as e: + formatted_message = f"API call failed with status code {e.status} ({e.reason}): {json.loads(e.body)['message']}" + click.echo(formatted_message, err=True) + exit(1) + except ValueError as e: + click.echo(str(e), err=True) + exit(1) + return wrapper