diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 000000000..abea959c7 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,151 @@ +name: Build Documentation + +on: + push: + branches: + - "**" + tags-ignore: + - "v*" + pull_request: + release: + types: + - published + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: "recursive" + + - name: Update system packages + run: sudo apt-get update -y + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: Install Python dependencies and update cert + run: | + pip install wheel boto3 && \ + pip install certifi -U && \ + pip install .[obj,dev] + + - name: Resolve the target CLI version + uses: actions/github-script@v7 + id: resolve-cli-version + with: + script: | + const latest_release = await github.rest.repos.getLatestRelease({ + owner: context.repo.owner, + repo: context.repo.repo + }); + + if (context.payload.release && latest_release.data.id == context.payload.release.id) { + let result = context.payload.release.tag_name; + + if (result.startsWith('v')) { + result = result.slice(1); + } + + return result; + } + + return '0.0.0.dev+' + context.sha.substring(0, 7); + result-encoding: string + + - name: Build the documentation + run: make create-version && make generate-docs + env: + # We need to define a token to prevent the CLI from + # attempting to do a first-time configuration. + LINODE_CLI_TOKEN: foobar + LINODE_CLI_VERSION: ${{ steps.resolve-cli-version.outputs.result }} + + - name: Upload the artifact + uses: actions/upload-artifact@v4 + with: + name: generated-docs-html + path: docs/build/html + + pages-commit: + name: Commit to Pages Branch + runs-on: ubuntu-latest + needs: + - build + # Make sure we avoid a race condition =) + concurrency: + group: "docs-stage" + cancel-in-progress: false + permissions: + contents: write + + if: (github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev' || github.ref_name == 'new/doc-generation' )) || (github.ref_type == 'tag') + steps: + - name: Checkout the documentation branch + continue-on-error: true + id: checkout-docs + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: "recursive" + ref: "_documentation" + + - name: Create the documentation branch if it does not already exist + if: "${{ steps.checkout-docs.outcome != 'success' }}" + run: git switch --orphan _documentation + + - name: Ensure any previous documentation for this branch is removed + run: rm -rf "./${{ github.ref_name }}" + + - name: Download the artifact from the build job + uses: actions/download-artifact@v4 + with: + name: generated-docs-html + path: "${{ github.ref_name }}" + + - name: Override the latest version if necessary + if: ${{ github.ref_type == 'tag' }} + run: | + rm -rf latest && cp -r ${{ github.ref_name }} latest + + - name: Overlay static files + run: | + echo "" > index.html; + touch .nojekyll + + - name: Commit and push this change + run: | + git config user.name "Documentation Publisher"; + git config user.email "dl-linode-dev-dx@akamai.com"; + git add .; + git commit --allow-empty -m "Build ${{ github.ref_name }} from ${{ github.sha }}"; + git push origin _documentation; + + upload-release-asset: + name: Upload Release Asset + runs-on: ubuntu-latest + needs: + - build + if: github.ref_type == 'tag' + steps: + - name: Download the artifact from the previous job + uses: actions/download-artifact@v4 + with: + name: generated-docs-html + path: ".build/${{ github.ref_name }}" + + - name: Archive the built documentation + run: | + cd .build/${{ github.ref_name }} && tar -czvf ../documentation.tar.gz * + + - name: Upload the documentation as a release asset + uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 + with: + files: .build/documentation.tar.gz + tag_name: ${{ github.ref_name }} diff --git a/.github/workflows/publish-wiki.yml b/.github/workflows/publish-wiki.yml deleted file mode 100644 index b063d0217..000000000 --- a/.github/workflows/publish-wiki.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Publish wiki -on: - push: - branches: [main] - paths: - - wiki/** - - .github/workflows/publish-wiki.yml -concurrency: - group: publish-wiki - cancel-in-progress: true -permissions: - contents: write -jobs: - publish-wiki: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: Andrew-Chen-Wang/github-wiki-action@50650fccf3a10f741995523cf9708c53cec8912a # pin@v4.4.0 diff --git a/.gitignore b/.gitignore index 77601cba7..09375efef 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ test/.env MANIFEST venv openapi*.yaml +_generated diff --git a/Makefile b/Makefile index a8884fdf2..bd5a5c823 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,8 @@ ifdef TEST_CASE TEST_CASE_COMMAND = -k $(TEST_CASE) endif +# TODO: Remove this workaround once the LKE docs issue has been resolved +SPEC := https://gist.githubusercontent.com/lgarber-akamai/3e1d77f08acf1a7b29d63a77b0b4a289/raw/12f18d7b7b54cf8587c9f24e72509b0e530e5760/openapi.yaml SPEC_VERSION ?= latest ifndef SPEC @@ -20,6 +22,10 @@ VERSION_FILE := ./linodecli/version.py VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of the Linode CLI.\n\"\"\"\n\n LINODE_CLI_VERSION ?= "0.0.0.dev" +# Documentation-related variables +SPHINX_BUILDER ?= html +SPHINX_GENERATED_PATH := ./docs/_generated + .PHONY: install install: check-prerequisites requirements build pip3 install --force dist/*.whl @@ -85,6 +91,18 @@ testall: .PHONY: test test: testunit +.PHONY: clean-docs-commands +clean-docs-commands: + rm -rf "$(SPHINX_GENERATED_PATH)" + +.PHONY: generate-docs +generate-docs-commands: bake clean-docs-commands + python3 -m linodecli generate-docs "$(SPHINX_GENERATED_PATH)" + +.PHONY: generate-docs +generate-docs: generate-docs-commands + cd docs && make $(SPHINX_BUILDER) + .PHONY: black black: black linodecli tests diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..ed8809902 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/commands/index.rst b/docs/commands/index.rst new file mode 100644 index 000000000..cd6468425 --- /dev/null +++ b/docs/commands/index.rst @@ -0,0 +1,8 @@ +Commands +======== + +.. toctree:: + :maxdepth: 4 + :glob: + + ../_generated/groups/* diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..34078c399 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,40 @@ +import json +from pathlib import Path + +DOCS_PATH = Path(__file__).parent.resolve() +BUILD_META_PATH = DOCS_PATH / "_generated" / "build_meta.json" + +if not BUILD_META_PATH.is_file(): + raise FileNotFoundError( + "Could not find build_meta file. " + "Was `linode-cli generate-docs` run before attempting to render this documentation?", + ) + +with open(BUILD_META_PATH, "r") as f: + build_meta = json.load(f) + +# Project information +project = "linode-cli" +copyright = "2024, Akamai Technologies Inc." +author = "Akamai Technologies Inc." +version = f"v{build_meta.get('cli_version')} (API v{build_meta.get('api_spec_version')})" + +# General configuration +extensions = ["sphinx_rtd_theme"] +source_suffix = ".rst" +exclude_patterns = [] +highlight_language = "bash" +templates_path = ["templates"] + +# HTML builder configuration +html_logo = "static/logo.svg" +html_favicon = "static/favicon.ico" +html_static_path = ["static"] +html_css_files = [ + "overlay.css" +] + +html_theme = "sphinx_rtd_theme" +html_theme_options = { + "style_nav_header_background": "#009CDE" +} diff --git a/docs/development/guides/01-overview.rst b/docs/development/guides/01-overview.rst new file mode 100644 index 000000000..3510819ec --- /dev/null +++ b/docs/development/guides/01-overview.rst @@ -0,0 +1,150 @@ +.. _development_overview: + +Overview +======== + +The following section outlines the core functions of the Linode CLI. + +OpenAPI Specification Parsing +----------------------------- + +Most Linode CLI commands (excluding `plugin commands `_) +are generated dynamically at build-time from the `Linode OpenAPI Specification `_, +which is also used to generate the `official Linode API documentation `_. + +Each OpenAPI spec endpoint method is parsed into an ``OpenAPIOperation`` object. +This object includes all necessary request and response arguments to create a command, +stored as ``OpenAPIRequestArg`` and `OpenAPIResponseAttr` objects respectively. +At runtime, the Linode CLI changes each ``OpenAPIRequestArg`` to an argparse argument and +each ``OpenAPIResponseAttr`` to an outputtable column. It can also manage complex structures like +nested objects and lists, resulting in commands and outputs that may not +exactly match the OpenAPI specification. + +OpenAPI Specification Extensions +-------------------------------- + +In order to better support the Linode CLI, the following `Specification Extensions `_ have been added to Linode's OpenAPI spec: + +.. list-table:: + + * - Attribute + - Location + - Purpose + + * - x-linode-cli-action + - method + - The action name for operations under this path. If not present, operationId is used. + + * - x-linode-cli-color + - property + - If present, defines key-value pairs of property value: color. Colors must be one of the `standard colors `_ that accepted by Rich. Must include a default. + + * - x-linode-cli-command + - path + - The command name for operations under this path. If not present, "default" is used. + + * - x-linode-cli-display + - property + - If truthy, displays this as a column in output. If a number, determines the ordering (left to right). + + * - x-linode-cli-format + - property + - Overrides the "format" given in this property for the CLI only. Valid values are ``file`` and `json`. + + * - x-linode-cli-skip + - path + - If present and truthy, this method will not be available in the CLI. + + * - x-linode-cli-allowed-defaults + - requestBody + - Tells the CLI what configured defaults apply to this request. Valid defaults are "region", "image", "authorized_users", "engine", and "type". + + * - x-linode-cli-nested-list + - content-type + - Tells the CLI to flatten a single object into multiple table rows based on the keys included in this value. Values should be comma-delimited JSON paths, and must all be present on response objects. When used, a new key ``_split`` is added to each flattened object whose value is the last segment of the JSON path used to generate the flattened object from the source. + + * - x-linode-cli-use-schema + - content-type + - Overrides the normal schema for the object and uses this instead. Especially useful when paired with :code:``x-linode-cli-nested-list``, allowing a schema to describe the flattened object instead of the original object. + + * - x-linode-cli-subtables + - content-type + - Indicates that certain response attributes should be printed in a separate "sub"-table. This allows certain endpoints with nested structures in the response to be displayed correctly. + +Baking +------ + +The "baking" process is run with ``make bake``, `make install`, and `make build` targets, +wrapping the ``linode-cli bake`` command. + +Objects representing each command are serialized into the `data-3` file via the `pickle `_ +package, and are included in release artifacts as a `data file `_. +This enables quick command loading at runtime and eliminates the need for runtime parsing logic. + +Configuration +------------- + +The Linode CLI can be configured using the ``linode-cli configure`` command, which allows users to +configure the following: + +- A Linode API token + - This can optionally be done using OAuth, see `OAuth Authentication <#oauth-authentication>`_ +- Default values for commonly used fields (e.g. region, image) +- Overrides for the target API URL (hostname, version, scheme, etc.) + +This command serves as an interactive prompt and outputs a configuration file to ``~/.config/linode-cli``. +This file is in a simple INI format and can be easily modified manually by users. + +Additionally, multiple users can be created for the CLI which can be designated when running commands using the ``--as-user`` argument +or using the ``default-user`` config variable. + +When running a command, the config file is loaded into a ``CLIConfig`` object stored under the `CLI.config` field. +This object allows various parts of the CLI to access the current user, the configured token, and any other CLI config values by name. + +The logic for the interactive prompt and the logic for storing the CLI configuration can be found in the +``configuration`` package. + +OAuth Authentication +-------------------- + +In addition to allowing users to configure a token manually, they can automatically generate a CLI token under their account using +an OAuth workflow. This workflow uses the `Linode OAuth API `_ to generate a temporary token, +which is then used to generate a long-term token stored in the CLI config file. + +The OAuth client ID is hardcoded and references a client under an officially managed Linode account. + +The rough steps of this OAuth workflow are as follows: + +1. The CLI checks whether a browser can be opened. If not, manually prompt the user for a token and skip. +2. Open a local HTTP server on an arbitrary port that exposes ``oauth-landing-page.html``. This will also extract the token from the callback. +3. Open the user's browser to the OAuth URL with the hardcoded client ID and the callback URL pointing to the local webserver. +4. Once the user authorizes the OAuth application, they will be redirected to the local webserver where the temporary token will be extracted. +5. With the extracted token, a new token is generated with the default callback and a name similar to ``Linode CLI @ localhost``. + +All the logic for OAuth token generation is stored in the ``configuration/auth.py`` file. + +Outputs +------- + +The Linode CLI uses the `Rich Python package `_ to render tables, colorize text, +and handle other complex terminal output operations. + +Output Overrides +---------------- + +For special cases where the desired output may not be possible using OpenAPI spec extensions alone, developers +can implement special override functions that are given the output JSON and print a custom output to stdout. + +These overrides are specified using the ``@output_override`` decorator and can be found in the `overrides.py` file. + +Command Completions +------------------- + +The Linode CLI allows users to dynamically generate shell completions for the Bash and Fish shells. +This works by rendering hardcoded templates for each baked/generated command. + +See ``completion.py`` for more details. + +.. rubric:: Next Steps + +To continue to the next step of this guide, continue to the :ref:`Setup page `. diff --git a/docs/development/guides/02-setup.rst b/docs/development/guides/02-setup.rst new file mode 100644 index 000000000..e5cd54c45 --- /dev/null +++ b/docs/development/guides/02-setup.rst @@ -0,0 +1,78 @@ +.. _development_setup: + +Setup +===== + +The following guide outlines to the process for setting up the Linode CLI for development. + +Cloning the Repository +---------------------- + +The Linode CLI repository can be cloned locally using the following command:: + + git clone git@github.com:linode/linode-cli.git + +If you do not have an SSH key configured, you can alternatively use the following command:: + + git clone https://github.com/linode/linode-cli.git + +Configuring a VirtualEnv (recommended) +-------------------------------------- + +A virtual env allows you to create virtual Python environment which can prevent potential +Python dependency conflicts. + +To create a VirtualEnv, run the following:: + + python3 -m venv .venv + +To enter the VirtualEnv, run the following command (NOTE: This needs to be run every time you open your shell):: + + source .venv/bin/activate + +Installing Project Dependencies +------------------------------- + +All Linode CLI Python requirements can be installed by running the following command:: + + make requirements + +Building and Installing the Project +----------------------------------- + +The Linode CLI can be built and installed using the ``make install`` target:: + + make install + +Alternatively you can build but not install the CLI using the ``make build`` target:: + + make build + +Optionally you can validate that you have installed a local version of the CLI using the ``linode-cli --version`` command:: + + linode-cli --version + + # Output: + # linode-cli 0.0.0 + # Built from spec version 4.173.0 + # + # The 0.0.0 implies this is a locally built version of the CLI + +Building Using a Custom OpenAPI Specification +--------------------------------------------- + +In some cases, you may want to build the CLI using a custom or modified OpenAPI specification. + +This can be achieved using the ``SPEC`` Makefile argument, for example:: + + # Download the OpenAPI spec + curl -o openapi.yaml https://raw.githubusercontent.com/linode/linode-api-docs/development/openapi.yaml + + # Make arbitrary changes to the spec + + # Build & install the CLI using the modified spec + make SPEC=$PWD/openapi.yaml install + +.. rubric:: Next Steps + +To continue to the next step of this guide, continue to the :ref:`Project Skeleton page `. \ No newline at end of file diff --git a/docs/development/guides/03-project-skeleton.rst b/docs/development/guides/03-project-skeleton.rst new file mode 100644 index 000000000..4525e5a02 --- /dev/null +++ b/docs/development/guides/03-project-skeleton.rst @@ -0,0 +1,164 @@ +.. _development_project_skeleton: + +Project Skeleton +================ + +This guide outlines the purpose of each file in the CLI. + +linode-cli +---------- + +Contains all the logic for the `linode-cli` executable. + +.. list-table:: + + * - File + - Purpose + + * - ``__init__.py`` + - Contains the main entrypoint for the CLI; routes top-level commands to their corresponding functions + + * - ``__main__.py`` + - Calls the project entrypoint in `__init__.py` + + * - ``api_request.py`` + - Contains logic for building API request bodies, making API requests, and handling API responses/errors + + * - ``arg_helpers.py`` + - Contains miscellaneous logic for registering common argparse arguments and loading the OpenAPI spec + + * - ``cli.py`` + - Contains the `CLI` class, which routes all the logic baking, loading, executing, and outputting generated CLI commands + + * - ``completion.py`` + - Contains all the logic for generating shell completion files (`linode-cli completion`) + + * - ``helpers.py`` + - Contains various miscellaneous helpers, especially relating to string manipulation, etc. + + * - ``oauth-landing-page.html`` + - The page to show users in their browser when the OAuth workflow is complete. + + * - ``output.py`` + - Contains all the logic for handling generated command outputs, including formatting tables, filtering JSON, etc. + + * - ``overrides.py`` + - Contains hardcoded output override functions for select CLI commands. + +baked +^^^^^ + +This directory contains logic related to parsing, processing, serializing, and executing the Linode OpenAPI spec. + +.. list-table:: + + * - File + - Purpose + + * - ``__init__.py`` + - Contains imports for certain classes in this package + + * - ``colors.py`` + - Contains logic for colorizing strings in CLI outputs (deprecated) + + * - ``operation.py`` + - Contains the logic to parse an `OpenAPIOperation` from the OpenAPI spec and generate/execute a corresponding argparse parser + + * - ``parsing.py`` + - Contains various logic related to parsing and translating text between markup languages. + + * - ``request.py`` + - Contains the `OpenAPIRequest` and `OpenAPIRequestArg` classes + + * - ``response.py`` + - Contains the `OpenAPIResponse` and `OpenAPIResponseAttr` classes + +configuration +^^^^^^^^^^^^^ + +Contains all logic related to the configuring the Linode CLI. + +.. list-table:: + + * - File + - Purpose + + * - ``__init__.py`` + - Contains imports for certain classes in this package + + * - ``auth.py`` + - Contains all the logic for the token generation OAuth workflow + + * - ``config.py`` + - Contains all the logic for loading, updating, and saving CLI configs + + * - ``helpers.py`` + - Contains various config-related helpers + +documentation +^^^^^^^^^^^^^ + +Contains the logic and templates to generate documentation for the Linode CLI. + +.. list-table:: + + * - File + - Purpose + + * - ``templates`` + - Contains the template files used to dynamically generate documentation pages + + * - ``__init__.py`` + - Contains imports for certain classes in this package + + * - ``generator.py`` + - Contains the logic to render and write documentation files + + * - ``template_data.py`` + - Contains all dataclasses used to render the documentation templates + +plugins +^^^^^^^ + +Contains the default plugins and plugin SDK for this project. + +.. list-table:: + + * - File + - Purpose + + * - ``__init__.py`` + - Contains imports for certain classes in this package + + * - ``plugins.py`` + - Contains the shared wrapper that allows plugins to access CLI functionality + +docs +---- + +Contains the Sphinx configuration used to render the Linode CLI's documentation. + +.. list-table:: + + * - File + - Purpose + + * - ``commands`` + - Contains non-generated documentation templates for the Linode CLI's commands. + + * - ``development`` + - Contains documentation templates for Linode CLI's development guide. + + * - ``conf.py`` + - Contains the Sphinx configuration for the Linode CLI's documentation + + * - ``index.rst`` + - The index/root document for the Linode CLI's documentation. + + * - ``Makefile`` + - Contains targets to render the documentation for this project + + +.. rubric:: Next Steps + +To continue to the next step of this guide, continue to the :ref:`Testing page `. diff --git a/docs/development/guides/04-testing.rst b/docs/development/guides/04-testing.rst new file mode 100644 index 000000000..03bf30fa5 --- /dev/null +++ b/docs/development/guides/04-testing.rst @@ -0,0 +1,34 @@ +.. _development_testing: + +Testing +======= + +This page gives an overview of how to run the various test suites for the Linode CLI. + +Before running any tests, built and installed the Linode CLI with your changes using ``make install``. + +Running Unit Tests +------------------ + +Unit tests can be run using the ``make testunit`` Makefile target. + +Running Integration Tests +------------------------- + +Running the tests locally is simple. The only requirements are that you export Linode API token as ``LINODE_CLI_TOKEN``:: + + export LINODE_CLI_TOKEN="your_token" + +More information on Managing Linode API tokens can be found in our [API Token Docs](https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/). + +In order to run the full integration test, run:: + + make testint + +To run specific test package, use environment variable ``INTEGRATION_TEST_PATH`` with `testint` command:: + + make INTEGRATION_TEST_PATH="cli" testint + +Lastly, to run specific test case, use environment variables ``TEST_CASE`` with `testint` command:: + + make TEST_CASE=test_help_page_for_non_aliased_actions testint diff --git a/docs/development/index.rst b/docs/development/index.rst new file mode 100644 index 000000000..a3752b770 --- /dev/null +++ b/docs/development/index.rst @@ -0,0 +1,15 @@ +Development +=========== + +This section will help you get started developing against and contributing to the Linode CLI. + +.. toctree:: + :maxdepth: 3 + :glob: + + guides/* + +Contributing +------------ + +Once you're ready to contribute a change to the project, please refer to our `Contributing Guide `_. \ No newline at end of file diff --git a/docs/general/guides/01-installation.rst b/docs/general/guides/01-installation.rst new file mode 100644 index 000000000..7eca6d63e --- /dev/null +++ b/docs/general/guides/01-installation.rst @@ -0,0 +1,93 @@ +Installation +============ + +This document outlines the officially supported installation methods for the Linode CLI. + +PyPi +---- + +The Linode CLI is automatically released to PyPI and can be installed using `pip`:: + + python3 -m pip install linode-cli + +The following can be used to upgrade an existing installation of the Linode CLI:: + + pip3 install linode-cli --upgrade + +Docker +------ + +The Linode CLI is also officially distributed as a `Docker Image `_. + +Authenticating with a Personal Access Token +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following command runs the `linodes list` command in a Docker container +using an existing `Personal Access Token `_:: + + docker run --rm -it -e LINODE_CLI_TOKEN=$LINODE_TOKEN linode/cli:latest linodes list + +Authenticating with an Existing Configuration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following command runs the `linodes list` command in a Docker container +using an existing Linode CLI configuration file:: + + docker run --rm -it -v $HOME/.config/linode-cli:/home/cli/.config/linode-cli linode/cli:latest linodes list + +GitHub Actions +-------------- + +The Linode CLI can automatically be installed in a GitHub Actions runner environment using the +`Setup Linode CLI Action `_: + +.. code-block:: yaml + + - name: Install the Linode CLI + uses: linode/action-linode-cli@v1 + with: + token: ${{ secrets.LINODE_TOKEN }} + + +Community Distributions +----------------------- + +The Linode CLI is available through unofficial channels thanks to our awesome community! +These distributions are not included in release testing. + +Homebrew +^^^^^^^^ + +.. code-block:: + + brew install linode-cli + brew upgrade linode-cli + +Building from Source +-------------------- + +In order to successfully build the CLI, your system will require the following: + +- The `make` command +- `python3` +- `pip3` (to install project dependencies) + +Before attempting a build, ensure all necessary dependencies have been installed:: + + make requirements + +Once everything is set up, you can initiate a build like so:: + + make build + +If desired, you may pass in ``SPEC=/path/to/openapi-spec`` when running ``build`` +or ``install``. This can be a URL or a path to a local spec, and that spec will +be used when generating the CLI. A yaml or json file is accepted. + +To install the package as part of the build process, use this command:: + + make install + +.. rubric:: Next Steps + +To continue to the next step of this guide, continue to the :ref:`Configuration page `. diff --git a/wiki/Configuration.md b/docs/general/guides/02-configuration.rst similarity index 63% rename from wiki/Configuration.md rename to docs/general/guides/02-configuration.rst index ed595da48..f931a215a 100644 --- a/wiki/Configuration.md +++ b/docs/general/guides/02-configuration.rst @@ -1,93 +1,101 @@ -# Configuration +.. _general_configuration: + +Configuration +============= The first time the CLI runs, it will prompt you to configure it. The CLI defaults to using web-based configuration, which is fast and convenient for users who have access to a browser. To manually configure the CLI or reconfigure it if your token expires, you can -run the `configure` command:: -```bash -linode-cli configure -``` +run the ``configure`` command:: + + linode-cli configure If you prefer to provide a token directly through the terminal, possibly because you don't have access to a browser where you're configuring the CLI, pass the -`--token` flag to the configure command as shown:: -```bash -linode-cli configure --token -``` +``--token`` flag to the configure command as shown:: + + linode-cli configure --token When configuring multiple users using web-based configuration, you may need to log out of cloud.linode.com before configuring a second user. -## Environment Variables +Environment Variables +^^^^^^^^^^^^^^^^^^^^^ If you prefer, you may store your token in an environment variable named -`LINODE_CLI_TOKEN` instead of using the configuration file. Doing so allows you -to bypass the initial configuration, and subsequent calls to `linode-cli configure` +``LINODE_CLI_TOKEN`` instead of using the configuration file. Doing so allows you +to bypass the initial configuration, and subsequent calls to ``linode-cli configure`` will allow you to set defaults without having to set a token. Be aware that if the environment variable should be unset, the Linode CLI will stop working until it is set again or the CLI is reconfigured with a token. You may also use environment variables to store your Object Storage Keys for -the `obj` plugin that ships with the CLI. To do so, simply set -`LINODE_CLI_OBJ_ACCESS_KEY` and `LINODE_CLI_OBJ_SECRET_KEY` to the +the ``obj`` plugin that ships with the CLI. To do so, simply set +``LINODE_CLI_OBJ_ACCESS_KEY`` and ``LINODE_CLI_OBJ_SECRET_KEY`` to the appropriate values. This allows using Linode Object Storage through the CLI without having a configuration file, which is desirable in some situations. -You may also specify the path to a custom Certificate Authority file using the `LINODE_CLI_CA` +You may also specify the path to a custom Certificate Authority file using the ``LINODE_CLI_CA`` environment variable. If you wish to hide the API Version warning you can use the `LINODE_CLI_SUPPRESS_VERSION_WARNING` environment variable. -## Configurable API URL +Configurable API URL +^^^^^^^^^^^^^^^^^^^^ In some cases you may want to run linode-cli against a non-default Linode API URL. This can be done using the following environment variables to override certain segments of the target API URL. -* `LINODE_CLI_API_HOST` - The host of the Linode API instance (e.g. `api.linode.com`) +* ``LINODE_CLI_API_HOST`` - The host of the Linode API instance (e.g. ``api.linode.com``) -* `LINODE_CLI_API_VERSION` - The Linode API version to use (e.g. `v4beta`) +* ``LINODE_CLI_API_VERSION`` - The Linode API version to use (e.g. ``v4beta``) -* `LINODE_CLI_API_SCHEME` - The request scheme to use (e.g. `https`) +* ``LINODE_CLI_API_SCHEME`` - The request scheme to use (e.g. ``https``) Alternatively, these values can be configured per-user using the ``linode-cli configure`` command. -## Multiple Users +Multiple Users +^^^^^^^^^^^^^^ If you use the Linode CLI to manage multiple Linode accounts, you may configure additional users using the ``linode-cli configure`` command. The CLI will automatically detect that a new user is being configured based on the token given. -## Displaying Configured Users +Displaying Configured Users +^^^^^^^^^^^^^^^^^^^^^^^^^^^ To see what users are configured, simply run the following:: -```bash -linode-cli show-users -``` + + linode-cli show-users The user who is currently active will be indicated by an asterisk. -## Changing the Active User +Changing the Active User +^^^^^^^^^^^^^^^^^^^^^^^^ You may change the active user for all requests as follows:: -```bash -linode-cli set-user USERNAME -``` + + linode-cli set-user USERNAME Subsequent CLI commands will be executed as that user by default. Should you wish to execute a single request as a different user, you can supply -the `--as-user` argument to specify the username you wish to act as for that +the ``--as-user`` argument to specify the username you wish to act as for that command. This *will not* change the active user. -## Removing Configured Users +Removing Configured Users +^^^^^^^^^^^^^^^^^^^^^^^^^ To remove a user from you previously configured, run:: -```bash -linode-cli remove-user USERNAME -``` + + linode-cli remove-user USERNAME Once a user is removed, they will need to be reconfigured if you wish to use the CLI for them again. + +.. rubric:: Next Steps + +To continue to the next step of this guide, continue to the :ref:`Usage page `. diff --git a/docs/general/guides/03-usage.rst b/docs/general/guides/03-usage.rst new file mode 100644 index 000000000..5b6826d5b --- /dev/null +++ b/docs/general/guides/03-usage.rst @@ -0,0 +1,176 @@ +.. _general_usage: + +Usage +===== + +The Linode CLI is invoked with the ``linode-cli``, +or with either of its two aliases available: ``linode`` and ``lin``. + +The CLI accepts two primary arguments, *command* and *action*:: + + linode-cli + +*command* is the part of the CLI you are interacting with, for example "linodes". +You can see a list of all available commands by using ``--help``:: + + linode-cli --help + + +*action* is the action you want to perform on a given command, for example "list". +You can see a list of all available actions for a command with the ``--help`` for +that command:: + + linode-cli linodes --help + +Some actions don't require any parameters, but many do. To see details on how +to invoke a specific action, use ``--help`` for that action:: + + linode-cli linodes create --help + +The first time you invoke the CLI, you will be asked to configure (see +"Configuration" below for details), and optionally select some default values +for "region," "image," and "type." If you configure these defaults, you may +omit them as parameters to actions and the default value will be used. + +Common Operations +----------------- + +List Linodes:: + + linode-cli linodes list + +List Linodes in a Region:: + + linode-cli linodes list --region us-east + +Create a Linode:: + + linode-cli linodes create --type g5-standard-2 --region us-east --image linode/debian9 --label cli-1 --root_pass + +Create a Linode using default settings:: + + linode-cli linodes create --label cli-2 --root_pass + +Reboot a Linode:: + + linode-cli linodes reboot 12345 + +View available Linode types:: + + linode-cli linodes types + +View your Volumes:: + + linode-cli volumes list + +View your Domains:: + + linode-cli domains list + +View records for a single Domain:: + + linode-cli domains records-list 12345 + +View your user:: + + linode-cli profile view + +Specifying List Arguments +------------------------- + +When running certain commands, you may need to specify multiple values for a list +argument. This can be done by specifying the argument multiple times for each +value in the list. For example, to create a Linode with multiple ``tags`` +you can execute the following:: + + linode-cli linodes create --region us-east --type g6-nanode-1 --tags tag1 --tags tag2 + +Lists consisting of nested structures can also be expressed through the command line. +Duplicated attribute will signal a different object. +For example, to create a Linode with a public interface on ``eth0`` and a VLAN interface +on ``eth1`` you can execute the following:: + + linode-cli linodes create \ + --region us-east --type g6-nanode-1 --image linode/ubuntu22.04 \ + --root_pass "myr00tp4ss123" \ + # The first interface (index 0) is defined with the public purpose + --interfaces.purpose public \ + # The second interface (index 1) is defined with the vlan purpose. + # The duplicate `interfaces.purpose` here tells the CLI to start building a new interface object. + --interfaces.purpose vlan --interfaces.label my-vlan + +Specifying Nested Arguments +--------------------------- + +When running certain commands, you may need to specify an argument that is nested +in another field. These arguments can be specified using a ``.`` delimited path to +the argument. For example, to create a firewall with an inbound policy of ``DROP`` +and an outbound policy of ``ACCEPT``, you can execute the following:: + + linode-cli firewalls create --label example-firewall --rules.outbound_policy ACCEPT --rules.inbound_policy DROP + +Special Arguments +----------------- + +In some cases, certain values for arguments may have unique functionality. + +Null Values +^^^^^^^^^^^ + +Arguments marked as nullable can be passed the value ``null`` to send an explicit null value to the Linode API:: + + linode-cli networking ip-update --rdns null 127.0.0.1 + +Empty Lists +^^^^^^^^^^^ + +List arguments can be passed the value ``[]`` to send an explicit empty list value to the Linode API:: + + linode-cli networking ip-share --linode_id 12345 --ips [] + +Suppressing Defaults +-------------------- + +If you configured default values for ``image``, ``authorized_users``, ``region``, +database ``engine``, and Linode ``type``, they will be sent for all requests that accept them +if you do not specify a different value. If you want to send a request *without* these +arguments, you must invoke the CLI with the ``--no-defaults`` option. + +For example, to create a Linode with no ``image`` after a default Image has been +configured, you would do this:: + + linode-cli linodes create --region us-east --type g5-standard-2 --no-defaults + +Suppressing Warnings +-------------------- + +In some situations, like when the CLI is out of date, it will generate a warning +in addition to its normal output. If these warnings can interfere with your +scripts or you otherwise want them disabled, simply add the ``--suppress-warnings`` +flag to prevent them from being emitted. + +## Suppressing Retries + +Sometimes the API responds with a error that can be ignored. For example a timeout +or nginx response that can't be parsed correctly, by default the CLI will retry +calls on these errors we've identified. If you'd like to disable this behavior for +any reason use the ``--no-retry`` flag. + +Shell Completion +---------------- + +To generate a completion file for a given shell type, use the ``completion`` command; +for example to generate completions for bash run:: + + linode-cli completion bash + +The output of this command is suitable to be included in the relevant completion +files to enable command completion on your shell. + +This command currently supports completions bash and fish shells. + +Use ``bashcompinit`` on zsh with the bash completions for support on zsh shells. + +.. rubric:: Next Steps + +To continue to the next step of this guide, continue to the :ref:`Output page `. diff --git a/wiki/Output.md b/docs/general/guides/04-output.rst similarity index 53% rename from wiki/Output.md rename to docs/general/guides/04-output.rst index 52db17fc7..c0147cf5c 100644 --- a/wiki/Output.md +++ b/docs/general/guides/04-output.rst @@ -1,58 +1,62 @@ -# Customizing Output +.. _general_output: -## Changing Output Fields +Customizing Output +================== + +Changing Output Fields +---------------------- By default, the CLI displays on some pre-selected fields for a given type of response. If you want to see everything, just ask:: -```bash -linode-cli linodes list --all -``` -Using `--all` will cause the CLI to display all returned columns of output. + linode-cli linodes list --all + +Using ``--all`` will cause the CLI to display all returned columns of output. Note that this will probably be hard to read on normal-sized screens for most actions. If you want even finer control over your output, you can request specific columns be displayed:: -```bash -linode-cli linodes list --format 'id,region,status,disk,memory,vcpus,transfer' -``` + + linode-cli linodes list --format 'id,region,status,disk,memory,vcpus,transfer' This will show some identifying information about your Linode as well as the resources it has access to. Some of these fields would be hidden by default - that's ok. If you ask for a field, it'll be displayed. -## Output Formatting +Output Formatting +----------------- While the CLI by default outputs human-readable tables of data, you can use the CLI to generate output that is easier to process. -## Machine Readable Output +Machine Readable Output +----------------------- To get more machine-readable output, simply request it:: -```bash -linode-cli linodes list --text -``` + + linode-cli linodes list --text If a tab is a bad delimiter, you can configure that as well:: -```bash -linode-cli linodes list --text --delimiter ';' -``` + + linode-cli linodes list --text --delimiter ';' You may also disable header rows (in any output format):: -```bash -linode-cli linodes list --no-headers --text -``` -## JSON Output + linode-cli linodes list --no-headers --text + +JSON Output +----------- To get JSON output from the CLI, simple request it:: -```bash -linode-cli linodes list --json --all -``` -While the `--all` is optional, you probably want to see all output fields in + linode-cli linodes list --json --all + +While the ``--all`` is optional, you probably want to see all output fields in your JSON output. If you want your JSON pretty-printed, we can do that too:: -```bash -linode-cli linodes list --json --pretty --all -``` + + linode-cli linodes list --json --pretty --all + +.. rubric:: Next Steps + +To continue to the next step of this guide, continue to the :ref:`Plugins page `. diff --git a/wiki/Plugins.md b/docs/general/guides/05-plugins.rst similarity index 63% rename from wiki/Plugins.md rename to docs/general/guides/05-plugins.rst index 00e299fd3..8ea11253b 100644 --- a/wiki/Plugins.md +++ b/docs/general/guides/05-plugins.rst @@ -1,28 +1,31 @@ -# Plugins +.. _general_plugins: + +Plugins +======= The Linode CLI allows its features to be expanded with plugins. Some official plugins come bundled with the CLI and are documented above. Additionally, anyone can write and distribute plugins for the CLI - these are called Third Party Plugins. To register a Third Party Plugin, use the following command:: -```bash -linode-cli register-plugin PLUGIN_MODULE_NAME -``` + + linode-cli register-plugin PLUGIN_MODULE_NAME Plugins should give the exact command required to register them. Once registered, the command to invoke the Third Party Plugin will be printed, and -it will appear in the plugin list when invoking `linode-cli --help`. +it will appear in the plugin list when invoking ``linode-cli --help``. To remove a previously registered plugin, use the following command:: -```bash -linode-cli remove-plugin PLUGIN_NAME -``` + + linode-cli remove-plugin PLUGIN_NAME This command accepts the name used to invoke the plugin in the CLI as it appears -in `linode-cli --help`, which may not be the same as the module name used to +in ``linode-cli --help``, which may not be the same as the module name used to register it. -## Developing Plugins +Developing Plugins +------------------ -For information on how To write your own Third Party Plugin, see the [Plugins documentation](https://github.com/linode/linode-cli/blob/main/linodecli/plugins/README.md) +For information on how To write your own Third Party Plugin, see the +:ref:`Plugins documentation `. diff --git a/docs/general/index.rst b/docs/general/index.rst new file mode 100644 index 000000000..d987881e0 --- /dev/null +++ b/docs/general/index.rst @@ -0,0 +1,9 @@ +General +======= + +This section details general information related to the installation, usage, and configuration of the Linode CLI. + +.. toctree:: + :glob: + + guides/* diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..c782246a1 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,21 @@ +linode-cli +========== + +Welcome to the `linode-cli` documentation! + +For installation instructions and usage guides, please +refer to the sidebar of this page or the Table of Contents below. + +.. image:: static/demo.gif + :alt: Linode CLI Demo + +Table of Contents +----------------- + +.. toctree:: + :maxdepth: 2 + + general/index.rst + development/index.rst + plugins/index.rst + commands/index.rst \ No newline at end of file diff --git a/docs/plugins/development.rst b/docs/plugins/development.rst new file mode 100644 index 000000000..1be1bf25d --- /dev/null +++ b/docs/plugins/development.rst @@ -0,0 +1,147 @@ +Development +=========== + +The Linode CLI supports embedded plugins, features that are hard-coded (instead +of generated as the rest of the CLI is) but are accessible directly through the +CLI as other features are. All plugins are found in this directory. + +Creating a Plugin +----------------- + +To create a plugin, simply drop a new python file into this directory or write a +Python module that presents the interface described below. If the +plugin is a Python module, make sure the ``call`` method is in the ``__init__.py`` +file in the root of the module. + +Plugins in this directory are called "Internal Plugins," and must meet the +following conditions: + +* Its name must be unique, both with the other plugins and with all commands + offered through the generated CLI +* Its name must not contain special characters, and should be easily to enter + on the command line +* It must contain a ``call(args, context)`` function for invocation +* It must support a ``--help`` command as all other CLI commands do. + +Plugins that are installed separately and registered with the ``register-plugin`` +command are called "Third Party Plugins," and must meet the following +conditions: + +* Its name must be unique, both with the internal plugins and all CLI operations +* It must contain a ``call(args, context)`` function for invocation +* It must contain a ``PLUGIN_NAME`` constant whose value is a string that does not + contain special characters, and should be easy to enter on the command line. +* It should support a `--help` command as all other CLI commands do. + +The Plugin Interface +-------------------- + +All plugins are either an individual python file or a Python module +that reside in this directory or installed separately. Plugins must have one function, ``call``, that +matches the following signature: + +.. code-block:: python + + def call(args, context): + """ + This is the function used to invoke the plugin. It will receive the remainder + of sys.argv after the plugin's name, and a context of user defaults and config + settings. + """ + +The PluginContext +^^^^^^^^^^^^^^^^^ + +The ``PluginContext`` class, passed as ``context`` to the ``call`` function, includes +all information the plugin is given during invocation. This includes the following: + +* ``token`` - The Personal Access Token registered with the CLI to make requests. +* ``client`` - The CLI Client object that can make authenticated requests on behalf + of the acting user. This is preferrable to using `requests` or another library + directly (see below). + +.. rubric:: CLI Client + +The CLI Client provided as ``context.client`` can make authenticated API calls on +behalf of the user using the provided ``call_operation`` method. This method is +invoked with a command and an action, and executes the given CLI command as if +it were entered into the command line, returning the resulting status code and +JSON data. + +Configuration +------------- + +Plugins can access the CLI's configuration through the CLI Client mentioned above. +Plugins are allowed to: + +* Read values from the current user's config +* Read and write their own values to the current user's config + +Any other operation is not supported and may break without notice. + +Methods +^^^^^^^ + +The ``Configuration`` class provides the following methods for plugins to use: + +**get_value(key)** Returns the value the current user has set for this key, or ``None`` +if the key does not exist. Currently supported keys are ``region``, ``type``, and ``image``. + +**plugin_set_value(key, value)** Sets a value in the user's config for this plugin. +Plugins can safely set values for any key, and they are namespaced away from other +config keys. + +**plugin_get_value(key)** Returns the value this plugin previously set for the given +key, or ``None`` if not set. Plugins should assume they are not configured if they +receive ``None`` when getting a value with this method. + +**write_config()** Writes config changes to disk. This is required to save changes +after calling ``plugin_set_value`` above. + +Sample Code +^^^^^^^^^^^ + +The following code manipulates and reads from the config in a plugin: + +.. code-block:: python + + def call(args, context): + # get a value from the user's config + default_region = context.client.config.get_value('region') + + # check if we set a value previously + our_value = context.client.config.plugin_get_value('configured') + + if our_value is None: + # plugin not configured - do configuration here + context.client.config.plugin_set_value('configured', 'yes') + + # save the config so changes take effect + context.client.config.write_config() + + # normal plugin code + +Development +----------- + +To develop a plugin, simply create a python source file in this directory that +has a ``call`` function as described above. To test, simply build the CLI as +normal (via ``make install``) or simply by running ``./setup.py install`` in the +root directory of the project (this installs the code without generating new +baked data, and will only work if you've installed the CLI via ``make install`` +at least once, however it's a lot faster). + +To develop a third party plugin, simply create and install your module and register +it to the CLI. As long as the ``PLUGIN_NAME`` doesn't change, updated installations +should invoke the new code. + +Examples +^^^^^^^^ + +This directory contains two example plugins, ``echo.py.example`` and +``regionstats.py.example``. To run these, simply remove the ``.example`` at the end +of the file and build the CLI as described above. + +`This directory `_ +contains an example Third Party Plugin module. This module is installable and +can be registered to the CLI. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst new file mode 100644 index 000000000..5c00558a6 --- /dev/null +++ b/docs/plugins/index.rst @@ -0,0 +1,13 @@ +.. _plugins: + +Plugins +======= + +This section details general information related to the usage and development of Linode CLI plugins. + +.. toctree:: + :maxdepth: 2 + :glob: + + development + plugins/* diff --git a/docs/static/demo.gif b/docs/static/demo.gif new file mode 100644 index 000000000..80a408ae9 Binary files /dev/null and b/docs/static/demo.gif differ diff --git a/docs/static/favicon.ico b/docs/static/favicon.ico new file mode 100644 index 000000000..f2c712fdb Binary files /dev/null and b/docs/static/favicon.ico differ diff --git a/docs/static/logo.svg b/docs/static/logo.svg new file mode 100644 index 000000000..1edc0c288 --- /dev/null +++ b/docs/static/logo.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/overlay.css b/docs/static/overlay.css new file mode 100644 index 000000000..fe9bfbdcc --- /dev/null +++ b/docs/static/overlay.css @@ -0,0 +1,113 @@ +/* Hack to force wrapping in table cells. */ +table { + width: 100% !important; +} + +tr { + width: 100% !important; +} + +td { + word-wrap: break-word !important; + white-space: normal !important; +} + +/* Make the content area a bit larger than default. */ +.wy-nav-content { + max-width: 1000px !important; +} + +/* Make the logo a bit larger than default */ +.logo { + width: 150px !important; +} + +/* +Custom formatting for actions +*/ +.action-has-keywords > h2 { + margin-bottom: 8px !important; +} + +.action-keyword { + margin-bottom: 16px !important; +} + +.action-keyword-key { + font-weight: 700 !important; +} + +.action-subheading { + margin-bottom: 4px !important; + font-weight: 700 !important; +} + +.action-subheading-description { + font-size: 90% !important; + margin-bottom: 12px !important; +} + +.action-section-header { + color: #606060 !important; + font-weight: 700 !important; + padding-top: 10px !important; + margin-bottom: 12px !important; + font-size: 95% !important; +} + +.action-keyword-values, +.action-table-field-name, +.action-table-field-required, +.action-table-field-type { + font-size: 85% !important; + font-family: SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace !important; +} + +.action-table-field-name { + font-weight: 600 !important; +} + +.action-table-field-example { + color: #707070 !important; + font-size: 90% !important; +} + +.action-table-field-required { + color: #E74C3C !important; + vertical-align: super !important; +} + +.action-argument-additional-details { + font-size: 95% !important; + font-weight: 580 !important; +} + +/* +Formatting for generated tables +*/ + +/* Prevent word wrapping on `Name` and `Example` fields */ +.action-argument-section-table > tbody tr > td:nth-child(1) *, +.action-argument-section-table > tbody tr > td:nth-child(3) *, +.action-parameter-table > tbody tr > td:nth-child(1) *, +.action-filterable-field-table > tbody tr > td:nth-child(1) *, +.action-attribute-section-table > tbody tr > td:nth-child(1) *, +.action-attribute-section-table > tbody tr > td:nth-child(3) * { + word-wrap: unset !important; + white-space: nowrap !important; +} + +/* Center-align `Type` columns */ +.action-argument-section-table > tbody tr > td:nth-child(2), +.action-argument-section-table > thead tr > th:nth-child(2), + +.action-attribute-section-table > tbody tr > td:nth-child(2), +.action-attribute-section-table > thead tr > th:nth-child(2), + +.action-parameter-table > tbody tr > td:nth-child(2), +.action-parameter-table > thead tr > th:nth-child(2), + +.action-filterable-field-table > tbody tr > td:nth-child(2), +.action-filterable-field-table > thead tr > th:nth-child(2) { + text-align: center !important; +} \ No newline at end of file diff --git a/docs/templates/layout.html b/docs/templates/layout.html new file mode 100644 index 000000000..0de1f9ff1 --- /dev/null +++ b/docs/templates/layout.html @@ -0,0 +1,4 @@ +{% extends "!layout.html" %} +{% block extrahead %} + +{% endblock %} diff --git a/linodecli/__init__.py b/linodecli/__init__.py index 776b6589f..046e262d9 100755 --- a/linodecli/__init__.py +++ b/linodecli/__init__.py @@ -24,6 +24,7 @@ from .cli import CLI from .completion import get_completions from .configuration import ENV_TOKEN_NAME +from .documentation.generator import DocumentationGenerator from .help_pages import ( HELP_TOPICS, print_help_action, @@ -101,7 +102,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements # handle a bake - this is used to parse a spec and bake it as a pickle if parsed.command == "bake": if parsed.action is None: - print("No spec provided, cannot bake") + print("No spec provided, cannot bake.", file=sys.stderr) sys.exit(ExitCodes.ARGUMENT_ERROR) bake_command(cli, parsed.action) sys.exit(ExitCodes.SUCCESS) @@ -109,6 +110,17 @@ def main(): # pylint: disable=too-many-branches,too-many-statements # if not spec was found and we weren't baking, we're doomed sys.exit(ExitCodes.ARGUMENT_ERROR) + if parsed.command == "generate-docs": + if parsed.action is None: + print( + "No directory provided, cannot generate documentation.", + file=sys.stderr, + ) + sys.exit(ExitCodes.ARGUMENT_ERROR) + + DocumentationGenerator().generate(cli, output_directory=parsed.action) + sys.exit(ExitCodes.SUCCESS) + if parsed.command == "register-plugin": if parsed.action is None: print("register-plugin requires a module name!") diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index 1494792cc..01d84e3bb 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -17,6 +17,7 @@ import openapi3.paths from openapi3.paths import Operation, Parameter +from linodecli.baked.parsing import process_arg_description from linodecli.baked.request import OpenAPIFilteringRequest, OpenAPIRequest from linodecli.baked.response import OpenAPIResponse from linodecli.exit_codes import ExitCodes @@ -285,6 +286,9 @@ def __init__(self, parameter: openapi3.paths.Parameter): """ self.name = parameter.name self.type = parameter.schema.type + self.description_rich, self.description = process_arg_description( + parameter.description or "" + ) def __repr__(self): return f"" @@ -357,7 +361,9 @@ def __init__( self.action = action self.summary = operation.summary - self.description = operation.description.split(".")[0] + self.description_rich, self.description = process_arg_description( + operation.description or "" + ) # The apiVersion attribute should not be specified as a positional argument self.params = [ @@ -387,6 +393,8 @@ def __init__( else [] ) + self.deprecated = operation.deprecated + @property def args(self): """ @@ -584,6 +592,7 @@ def _add_args_post_put( arg_type = ( arg.item_type if arg.datatype == "array" else arg.datatype ) + arg_type_handler = TYPES[arg_type] if arg.nullable: @@ -781,29 +790,43 @@ def parse_args(self, args: Any) -> argparse.Namespace: :rtype: Namespace """ - # build an argparse - parser = argparse.ArgumentParser( + parser, list_items = self.build_parser() + + parsed = parser.parse_args(args) + + if self.method in ("post", "put"): + self._validate_parent_child_conflicts(parsed) + + return self._handle_list_items(list_items, parsed) + + def build_parser( + self, + ) -> Tuple[argparse.ArgumentParser, List[Tuple[str, str]]]: + """ + Builds and returns an argument parser for this operation. + + :returns: A tuple containing the new argument parser and a list of tuples, each + representing a single list argument. + """ + + result = argparse.ArgumentParser( prog=f"linode-cli {self.command} {self.action}", description=self.summary, ) + for param in self.params: - parser.add_argument( + result.add_argument( param.name, metavar=param.name, type=TYPES[param.type] ) list_items = [] if self.method == "get": - self._add_args_filter(parser) + self._add_args_filter(result) elif self.method in ("post", "put"): - list_items = self._add_args_post_put(parser) + list_items = self._add_args_post_put(result) - parsed = parser.parse_args(args) - - if self.method in ("post", "put"): - self._validate_parent_child_conflicts(parsed) - - return self._handle_list_items(list_items, parsed) + return result, list_items @staticmethod def _resolve_operation_docs_url_legacy( diff --git a/linodecli/baked/parsing.py b/linodecli/baked/parsing.py index 5125e45dc..602977918 100644 --- a/linodecli/baked/parsing.py +++ b/linodecli/baked/parsing.py @@ -5,15 +5,15 @@ import functools import re from html import unescape -from typing import List, Tuple +from typing import List, Optional, Tuple # Sentence delimiter, split on a period followed by any type of # whitespace (space, new line, tab, etc.) -REGEX_SENTENCE_DELIMITER = re.compile(r"\.(?:\s|$)") +REGEX_SENTENCE_DELIMITER = re.compile(r"\.(?:\s|$)", flags=re.M) # Matches on pattern __prefix__ at the beginning of a description # or after a comma -REGEX_TECHDOCS_PREFIX = re.compile(r"(?:, |\A)__([\w-]+)__") +REGEX_TECHDOCS_PREFIX = re.compile(r"(?:, |\A)__([^_]+)__") # Matches on pattern [link title](https://.../) REGEX_MARKDOWN_LINK = re.compile(r"\[(?P.*?)]\((?P.*?)\)") @@ -121,23 +121,35 @@ def get_short_description(description: str) -> str: :rtype: set """ - target_lines = description.splitlines() - relevant_lines = None - - for i, line in enumerate(target_lines): + def __simplify(sentence: str) -> Optional[str]: # Edge case for descriptions starting with a note - if line.lower().startswith("__note__"): - continue + if sentence.lower().startswith("__note__"): + return None + + sentence = strip_techdocs_prefixes(sentence) - relevant_lines = target_lines[i:] - break + # Check that the sentence still has content after stripping prefixes + if len(sentence) < 2: + return None - if relevant_lines is None: + return sentence + "." + + # Find the first relevant sentence + result = next( + simplified + for simplified in iter( + __simplify(sentence) + for sentence in REGEX_SENTENCE_DELIMITER.split(description) + ) + if simplified is not None + ) + + if result is None: raise ValueError( f"description does not contain any relevant lines: {description}", ) - return REGEX_SENTENCE_DELIMITER.split("\n".join(relevant_lines), 1)[0] + "." + return result def strip_techdocs_prefixes(description: str) -> str: @@ -150,11 +162,7 @@ def strip_techdocs_prefixes(description: str) -> str: :returns: The stripped description :rtype: str """ - result_description = REGEX_TECHDOCS_PREFIX.sub( - "", description.lstrip() - ).lstrip() - - return result_description + return REGEX_TECHDOCS_PREFIX.sub("", description.lstrip()).lstrip() def process_arg_description(description: str) -> Tuple[str, str]: @@ -173,12 +181,12 @@ def process_arg_description(description: str) -> Tuple[str, str]: return "", "" result = get_short_description(description) - result = strip_techdocs_prefixes(result) result = result.replace("\n", " ").replace("\r", " ") - description, links = extract_markdown_links(result) + # NOTE: Links should only be separated from Rich Markdown links + result_no_links, links = extract_markdown_links(result) if len(links) > 0: - description += f" See: {'; '.join(links)}" + result_no_links += f" See: {'; '.join(links)}" - return unescape(markdown_to_rich_markup(description)), unescape(description) + return unescape(markdown_to_rich_markup(result_no_links)), unescape(result) diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 9cf45c207..bee09c669 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -105,6 +105,15 @@ def __init__( #: Whether null is an acceptable value for this attribute self.nullable = schema.nullable + #: Whether this attribute is deprecated. + self.deprecated = schema.deprecated or False + + #: If true, this argument will not be returned in the API response. + self.write_only = schema.writeOnly or False + + #: An example value for this attribute + self.example = schema.example + # handle the type for list values if this is an array if self.datatype == "array" and schema.items: self.item_type = schema.items.type diff --git a/linodecli/baked/response.py b/linodecli/baked/response.py index 323c3ddef..353fb269e 100644 --- a/linodecli/baked/response.py +++ b/linodecli/baked/response.py @@ -4,6 +4,8 @@ from openapi3.paths import MediaType +from linodecli.baked.parsing import process_arg_description + def _is_paginated(response): """ @@ -52,8 +54,10 @@ def __init__(self, name, schema, prefix=None, nested_list_depth=0): self.nested_list_depth = nested_list_depth #: The description of this argument, for help display. Only used for filterable attributes. - self.description = ( - schema.description.split(".")[0] if schema.description else "" + self.description_rich, self.description = ( + process_arg_description(schema.description) + if schema.description + else ("", "") ) #: No response model fields are required. This is only used for filterable attributes. @@ -76,11 +80,17 @@ def __init__(self, name, schema, prefix=None, nested_list_depth=0): #: How we should associate values of this attribute to output colors self.color_map = schema.extensions.get("linode-cli-color") + #: An example value for this attribute. + self.example = schema.example + #: The type for items in this attribute, if this attribute is a list self.item_type = None if schema.type == "array": self.item_type = schema.items.type + if schema.items.example: + self.example = schema.items.example + @property def path(self): """ @@ -173,6 +183,9 @@ def _parse_response_model(schema, prefix=None, nested_list_depth=0): return attrs for k, v in schema.properties.items(): + if v.writeOnly: + continue + pref = prefix + "." + k if prefix else k if v.type == "object": diff --git a/linodecli/cli.py b/linodecli/cli.py index a9f61a86c..feb017e9f 100644 --- a/linodecli/cli.py +++ b/linodecli/cli.py @@ -6,6 +6,7 @@ import pickle import sys from sys import version_info +from typing import IO, Any, Dict, Optional from openapi3 import OpenAPI @@ -40,7 +41,7 @@ def __init__(self, version, base_url, skip_config=False): self.config = CLIConfig(self.base_url, skip_config=skip_config) self.load_baked() - def bake(self, spec): + def bake(self, spec: Dict[str, Any], file: Optional[IO[bytes]] = None): """ Generates ops and bakes them to a pickle """ @@ -78,12 +79,15 @@ def bake(self, spec): self.ops["_spec_version"] = self.spec.info.version self.ops["_spec"] = self.spec - # finish the baking - data_file = self._get_data_file() - with open(data_file, "wb") as f: + # Serialize the baked spec + if file is not None: + pickle.dump(self.ops, file) + return + + with open(self._get_data_file(), "wb") as f: pickle.dump(self.ops, f) - def load_baked(self): + def load_baked(self, file: Optional[IO[bytes]] = None): """ Loads a baked spec representation from a baked pickle """ @@ -91,21 +95,26 @@ def load_baked(self): data_path = os.path.join( os.path.dirname(os.path.realpath(__file__)), data_file ) - if os.path.exists(data_path): - with open(data_path, "rb") as f: - self.ops = pickle.load(f) - if "_base_url" in self.ops: - self.base_url = self.ops.pop("_base_url") - if "_spec_version" in self.ops: - self.spec_version = self.ops.pop("_spec_version") - if "_spec" in self.ops: - self.spec = self.ops.pop("_spec") - else: + if not os.path.exists(data_path): print( "No spec baked. Please bake by calling this script as follows:" ) print(" python3 gen_cli.py bake /path/to/spec") self.ops = None # this signals __init__.py to give up + return + + if file is not None: + self.ops = pickle.load(file) + else: + with open(data_path, "rb") as f: + self.ops = pickle.load(f) + + if "_base_url" in self.ops: + self.base_url = self.ops.pop("_base_url") + if "_spec_version" in self.ops: + self.spec_version = self.ops.pop("_spec_version") + if "_spec" in self.ops: + self.spec = self.ops.pop("_spec") def _get_data_file(self): """ diff --git a/linodecli/documentation/__init__.py b/linodecli/documentation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/linodecli/documentation/filters.py b/linodecli/documentation/filters.py new file mode 100644 index 000000000..155effca2 --- /dev/null +++ b/linodecli/documentation/filters.py @@ -0,0 +1,45 @@ +""" +Contains custom filters exposed to the documentation templates. +""" + +import math +from typing import Callable, Dict + + +def get_filter_map() -> Dict[str, Callable]: + """ + Returns a map used to define filters used by the documentation template. + + :returns: The filter map. + """ + + return { + "truncate_middle": _filter_truncate_middle, + } + + +def _filter_truncate_middle( + target: str, length: int = 64, middle: str = "..." +) -> str: + """ + Filter to truncate the given string with a centered truncation. + + For example:: + + {{ "totruncate" | truncate_middle(length=6) }} # tot...ate + + :param target: The string to truncate. + :param length: The maximum length of the string, not including truncation characters. + :param middle: The string to use in between the two string segments. + + :returns: The truncated string. + """ + target_length = len(target) + + if target_length <= length: + return target + + target_length_half = math.ceil(target_length / 2) + offset = math.ceil((target_length - length) / 2) + + return f"{target[:target_length_half-offset]}{middle}{target[target_length_half+offset:]}" diff --git a/linodecli/documentation/generator.py b/linodecli/documentation/generator.py new file mode 100644 index 000000000..552b3a091 --- /dev/null +++ b/linodecli/documentation/generator.py @@ -0,0 +1,73 @@ +""" +Contains the primary class for generating documentation files. +""" + +import json +from dataclasses import asdict +from pathlib import Path + +from jinja2 import Environment, PackageLoader, select_autoescape + +from linodecli.cli import CLI +from linodecli.documentation.filters import get_filter_map +from linodecli.documentation.template_data import BuildMeta, Root + +TEMPLATE_NAME_GROUP = "group.rst.j2" + +OUTPUT_PATH_BUILD_META = "build_meta.json" +OUTPUT_PATH_GROUP_FORMAT = "groups/{name}.rst" + + +class DocumentationGenerator: + """ + The primary class responsible for generating Linode CLI documentation. + """ + + def __init__(self): + self._template_env = Environment( + loader=PackageLoader("linodecli.documentation", "templates"), + extensions=["jinja2.ext.do"], + autoescape=select_autoescape(), + trim_blocks=True, + lstrip_blocks=True, + ) + + self._template_env.filters.update(get_filter_map()) + + def generate(self, cli: CLI, output_directory: str = "./docs/_generated"): + """ + Generates the relevant documentation for the given CLI + to the given `output_directory`. + + :param cli: The main Linode CLI object to generate documentation for. + :param output_directory: The parent directory to generate the documentation under. + """ + + build_meta = BuildMeta( + cli_version=cli.version, + api_spec_version=cli.spec_version, + ) + + root_data = Root.from_cli(cli) + + output_path = Path(output_directory) + output_path.mkdir(parents=True, exist_ok=True) + + # Write the build data to a JSON file so it can be consumed from the + # Sphinx config. + with open( + output_path / OUTPUT_PATH_BUILD_META, "w", encoding="utf-8" + ) as f: + json.dump(asdict(build_meta), f) + + # Generate a documentation file for each CLI group. + for group in root_data.groups: + output_path_group = output_path / OUTPUT_PATH_GROUP_FORMAT.format( + name=group.name + ) + output_path_group.parent.mkdir(parents=True, exist_ok=True) + + # Render & write the group documentation + self._template_env.get_template(TEMPLATE_NAME_GROUP).stream( + asdict(group), build_meta=build_meta + ).dump(str(output_path_group)) diff --git a/linodecli/documentation/template_data/__init__.py b/linodecli/documentation/template_data/__init__.py new file mode 100644 index 000000000..14e7dcecc --- /dev/null +++ b/linodecli/documentation/template_data/__init__.py @@ -0,0 +1,11 @@ +""" +Contains all structures used to render static documentation templates. +""" + +from .action import * +from .argument import * +from .attribute import * +from .field_section import * +from .group import * +from .param import * +from .root import * diff --git a/linodecli/documentation/template_data/action.py b/linodecli/documentation/template_data/action.py new file mode 100644 index 000000000..6c1f075bf --- /dev/null +++ b/linodecli/documentation/template_data/action.py @@ -0,0 +1,152 @@ +""" +Contains the template data for Linode CLI actions. +""" + +from dataclasses import dataclass, field +from io import StringIO +from typing import List, Optional, Self, Set + +from linodecli.baked import OpenAPIOperation +from linodecli.baked.request import OpenAPIRequestArg +from linodecli.documentation.template_data.argument import Argument +from linodecli.documentation.template_data.attribute import ResponseAttribute +from linodecli.documentation.template_data.field_section import FieldSection +from linodecli.documentation.template_data.param import Param +from linodecli.documentation.template_data.util import ( + _format_usage_text, + _markdown_to_rst, + _normalize_padding, +) + + +@dataclass +class Action: + """ + Represents a single generated Linode CLI command/action. + """ + + command: str + action: List[str] + + usage: Optional[str] = None + summary: Optional[str] = None + description: Optional[str] = None + api_documentation_url: Optional[str] = None + deprecated: bool = False + parameters: List[Param] = field(default_factory=lambda: []) + samples: List[str] = field(default_factory=lambda: []) + attributes: List[ResponseAttribute] = field(default_factory=lambda: []) + filterable_attributes: List[ResponseAttribute] = field( + default_factory=lambda: [] + ) + + argument_sections: List[FieldSection[Argument]] = field( + default_factory=lambda: [] + ) + argument_sections_names: Set[str] = field(default_factory=lambda: {}) + + attribute_sections: List[FieldSection[ResponseAttribute]] = field( + default_factory=lambda: [] + ) + attribute_sections_names: Set[str] = field(default_factory=lambda: {}) + + @classmethod + def from_openapi(cls, operation: OpenAPIOperation) -> Self: + """ + Returns a new Action object initialized using values + from the given operation. + + :param operation: The operation to initialize the object with. + + :returns: The initialized object. + """ + + result = cls( + command=operation.command, + action=[operation.action] + (operation.action_aliases or []), + summary=_markdown_to_rst(operation.summary), + description=( + _markdown_to_rst(operation.description) + if operation.description != "" + else None + ), + usage=cls._get_usage(operation), + api_documentation_url=operation.docs_url, + deprecated=operation.deprecated is not None + and operation.deprecated, + ) + + if operation.samples: + result.samples = [ + _normalize_padding(sample["source"]) + for sample in operation.samples + ] + + if operation.params: + result.parameters = [ + Param.from_openapi(param) for param in operation.params + ] + + if operation.method == "get" and operation.response_model.is_paginated: + result.filterable_attributes = sorted( + [ + ResponseAttribute.from_openapi(attr) + for attr in operation.response_model.attrs + if attr.filterable + ], + key=lambda v: v.name, + ) + + if operation.args: + result.argument_sections = FieldSection.from_iter( + iter( + Argument.from_openapi(arg) + for arg in operation.args + if isinstance(arg, OpenAPIRequestArg) and not arg.read_only + ), + get_parent=lambda arg: arg.parent if arg.is_child else None, + sort_key=lambda arg: ( + not arg.required, + "." in arg.path, + arg.path, + ), + ) + + result.argument_sections_names = { + section.name for section in result.argument_sections + } + + if operation.response_model.attrs: + result.attribute_sections = FieldSection.from_iter( + iter( + ResponseAttribute.from_openapi(attr) + for attr in operation.response_model.attrs + ), + get_parent=lambda attr: ( + attr.name.split(".", maxsplit=1)[0] + if "." in attr.name + else None + ), + sort_key=lambda attr: attr.name, + ) + + result.attribute_sections_names = { + section.name for section in result.attribute_sections + } + + return result + + @staticmethod + def _get_usage(operation: OpenAPIOperation) -> str: + """ + Returns the formatted argparse usage string for the given operation. + + :param: operation: The operation to get the usage string for. + + :returns: The formatted usage string. + """ + + usage_io = StringIO() + operation.build_parser()[0].print_usage(file=usage_io) + + return _format_usage_text(usage_io.getvalue()) diff --git a/linodecli/documentation/template_data/argument.py b/linodecli/documentation/template_data/argument.py new file mode 100644 index 000000000..62ccfffba --- /dev/null +++ b/linodecli/documentation/template_data/argument.py @@ -0,0 +1,113 @@ +""" +Contains the template data for Linode CLI arguments. +""" + +import json +import sys +from dataclasses import dataclass, field +from typing import Any, List, Optional, Self + +from linodecli.baked.request import OpenAPIRequestArg +from linodecli.documentation.template_data.util import ( + _format_type, + _markdown_to_rst, +) + + +@dataclass +class Argument: + """ + Represents a single argument for a command/action. + """ + + path: str + required: bool + type: str + + is_json: bool = False + depth: int = 0 + description: Optional[str] = None + example: Optional[Any] = None + + is_parent: bool = False + is_child: bool = False + parent: Optional[str] = None + + additional_details: List[str] = field(default_factory=lambda: []) + + @staticmethod + def _format_example(arg: OpenAPIRequestArg) -> Optional[str]: + """ + Returns a formatted example value for the given argument. + + :param arg: The argument to get an example for. + + :returns: The formatted example if it exists, else None. + """ + + example = arg.example + + if not example: + return None + + if arg.datatype == "object": + return json.dumps(arg.example) + + if arg.datatype.startswith("array"): + # We only want to show one entry for list arguments. + if isinstance(example, list): + if len(example) < 1: + print( + f"WARN: List example does not have any elements: {example}", + file=sys.stderr, + ) + return None + + example = example[0] + + if isinstance(example, bool): + return "true" if example else "false" + + return str(example) + + @classmethod + def from_openapi(cls, arg: OpenAPIRequestArg) -> Self: + """ + Returns a new Argument object initialized using values + from the given OpenAPI request argument. + + :param arg: The OpenAPI request argument to initialize the object with. + + :returns: The initialized object. + """ + + additional_details = [] + + if arg.nullable: + additional_details.append("nullable") + + if arg.deprecated: + additional_details.append("deprecated") + + if arg.write_only: + additional_details.append("write-only") + + return cls( + path=arg.path, + required=arg.required, + type=_format_type( + arg.datatype, item_type=arg.item_type, _format=arg.format + ), + is_json=arg.format == "json", + is_parent=arg.is_parent, + parent=arg.parent, + is_child=arg.is_child, + depth=arg.depth, + description=( + _markdown_to_rst(arg.description) + if arg.description != "" + else None + ), + example=cls._format_example(arg), + additional_details=additional_details, + ) diff --git a/linodecli/documentation/template_data/attribute.py b/linodecli/documentation/template_data/attribute.py new file mode 100644 index 000000000..70dcb8ec9 --- /dev/null +++ b/linodecli/documentation/template_data/attribute.py @@ -0,0 +1,71 @@ +""" +Contains the template data for Linode CLI response attributes. +""" + +import json +from dataclasses import dataclass +from typing import Any, Optional, Self + +from linodecli.baked.response import OpenAPIResponseAttr +from linodecli.documentation.template_data.util import ( + _format_type, + _markdown_to_rst, +) + + +@dataclass +class ResponseAttribute: + """ + Represents a single filterable attribute for a list command/action. + """ + + name: str + type: str + + description: Optional[str] + example: Optional[Any] + + @staticmethod + def _format_example(attr: OpenAPIResponseAttr) -> Optional[str]: + """ + Returns a formatted example value for the given response attribute. + + :param attr: The attribute to get an example for. + + :returns: The formatted example if it exists, else None. + """ + + example = attr.example + + if not example: + return None + + if attr.datatype in ["object", "array"]: + return json.dumps(attr.example) + + if isinstance(example, bool): + return "true" if example else "false" + + return str(example) + + @classmethod + def from_openapi(cls, attr: OpenAPIResponseAttr) -> Self: + """ + Returns a new FilterableAttribute object initialized using values + from the given filterable OpenAPI response attribute. + + :param attr: The OpenAPI response attribute to initialize the object with. + + :returns: The initialized object. + """ + + return cls( + name=attr.name, + type=_format_type(attr.datatype, item_type=attr.item_type), + description=( + _markdown_to_rst(attr.description) + if attr.description != "" + else None + ), + example=cls._format_example(attr), + ) diff --git a/linodecli/documentation/template_data/field_section.py b/linodecli/documentation/template_data/field_section.py new file mode 100644 index 000000000..0e1076b1f --- /dev/null +++ b/linodecli/documentation/template_data/field_section.py @@ -0,0 +1,60 @@ +""" +Contains the template data for field sections. +""" + +from collections import defaultdict +from dataclasses import dataclass +from typing import ( + Any, + Callable, + Generic, + Iterable, + List, + Optional, + Self, + TypeVar, +) + +T = TypeVar("T") + + +@dataclass +class FieldSection(Generic[T]): + """ + Represents a single section of arguments. + """ + + name: str + entries: List[T] + + @classmethod + def from_iter( + cls, + data: Iterable[T], + get_parent: Callable[[T], Optional[str]], + sort_key: Callable[[T], Any], + ) -> List[Self]: + """ + Builds a list of FieldSection created from the given data using the given functions. + + :param data: The data to partition. + :param get_parent: A function returning the parent of this entry. + :param sort_key: A function passed into the `key` argument of the sort function. + + :returns: The built list of sections. + """ + + sections = defaultdict(lambda: []) + + for entry in data: + parent = get_parent(entry) + + sections[parent if parent is not None else ""].append(entry) + + return sorted( + [ + FieldSection(name=key, entries=sorted(section, key=sort_key)) + for key, section in sections.items() + ], + key=lambda section: section.name, + ) diff --git a/linodecli/documentation/template_data/group.py b/linodecli/documentation/template_data/group.py new file mode 100644 index 000000000..40a45c22f --- /dev/null +++ b/linodecli/documentation/template_data/group.py @@ -0,0 +1,57 @@ +""" +Contains the template data for Linode CLI groups. +""" + +from dataclasses import dataclass +from typing import Dict, List, Self + +from linodecli.baked import OpenAPIOperation +from linodecli.documentation.template_data.action import Action +from linodecli.helpers import sorted_actions_smart + +# Manual corrections to the generated "pretty" names for command groups. +GROUP_NAME_CORRECTIONS = { + "lke": "LKE", + "nodebalancers": "NodeBalancer", + "sshkeys": "SSH Keys", + "vlans": "VLANs", + "vpcs": "VPCs", +} + + +@dataclass +class Group: + """ + Represents a single "group" of commands/actions as defined by the Linode API. + """ + + name: str + pretty_name: str + actions: List[Action] + + @classmethod + def from_openapi( + cls, name: str, group: Dict[str, OpenAPIOperation] + ) -> Self: + """ + Returns a new Group object initialized using values + from the given name and group mapping. + + :param name: The name/key of the group. + :param group: A mapping between action names and their corresponding OpenAPIOperations. + + :returns: The initialized object. + """ + + return cls( + name=name, + pretty_name=( + GROUP_NAME_CORRECTIONS[name] + if name in GROUP_NAME_CORRECTIONS + else name.title().replace("-", " ") + ), + actions=sorted_actions_smart( + [Action.from_openapi(action) for action in group.values()], + key=lambda v: v.action[0], + ), + ) diff --git a/linodecli/documentation/template_data/param.py b/linodecli/documentation/template_data/param.py new file mode 100644 index 000000000..22b3deade --- /dev/null +++ b/linodecli/documentation/template_data/param.py @@ -0,0 +1,45 @@ +""" +Contains the template data for Linode CLI params. +""" + +from dataclasses import dataclass +from typing import Optional, Self + +from linodecli.baked.operation import OpenAPIOperationParameter +from linodecli.documentation.template_data.util import ( + _format_type, + _markdown_to_rst, +) + + +@dataclass +class Param: + """ + Represents a single URL parameter for a command/action. + """ + + name: str + type: str + + description: Optional[str] = None + + @classmethod + def from_openapi(cls, param: OpenAPIOperationParameter) -> Self: + """ + Returns a new Param object initialized using values + from the given OpenAPI parameter. + + :param param: The OpenAPI parameter to initialize the object with. + + :returns: The initialized object. + """ + + return cls( + name=param.name, + type=_format_type(param.type), + description=( + _markdown_to_rst(param.description) + if param.description is not None + else None + ), + ) diff --git a/linodecli/documentation/template_data/root.py b/linodecli/documentation/template_data/root.py new file mode 100644 index 000000000..78b080801 --- /dev/null +++ b/linodecli/documentation/template_data/root.py @@ -0,0 +1,49 @@ +""" +Contains root template data for the Linode CLI. +""" + +from dataclasses import dataclass +from typing import List, Self + +from linodecli.cli import CLI +from linodecli.documentation.template_data.group import Group + + +@dataclass +class Root: + """ + The root template data structure for the Linode CLI. + """ + + groups: List[Group] + + @classmethod + def from_cli(cls, cli: CLI) -> Self: + """ + Returns a new Root object initialized using values + from the given CLI. + + :param cli: The CLI to initialize the object with. + + :returns: The initialized object. + """ + + return cls( + groups=sorted( + [ + Group.from_openapi(key, group) + for key, group in cli.ops.items() + ], + key=lambda v: v.name, + ), + ) + + +@dataclass +class BuildMeta: + """ + Contains metadata about a single documentation build. + """ + + cli_version: str + api_spec_version: str diff --git a/linodecli/documentation/template_data/util.py b/linodecli/documentation/template_data/util.py new file mode 100644 index 000000000..c55eaa2ab --- /dev/null +++ b/linodecli/documentation/template_data/util.py @@ -0,0 +1,162 @@ +""" +Contains various utility functions related to documentation generation. +""" + +import math +import re +from typing import Optional + +from linodecli.baked.parsing import REGEX_MARKDOWN_LINK + +REGEX_MARKDOWN_CODE_TAG = re.compile(r"`(?P[^`\s]+)`") +REGEX_USAGE_TOKEN = re.compile(r"(\[[^\[\]]+]|\S+)") +REGEX_PADDING_CHARACTER = re.compile(r"(^ +)", flags=re.M) + +# Contains translations between OpenAPI data types and the condensed doc types. +OPENAPI_TYPE_FMT_TRANSLATION = { + "string": "str", + "boolean": "bool", + "number": "float", + "integer": "int", +} + + +def _normalize_padding(text: str, pad: str = " " * 4) -> str: + """ + Normalizes the padding for the given text using the given pad character. + + :param text: The text to normalize. + :param pad: The string to pad with. + + :returns: The normalized text. + """ + + padding_lengths = [ + len(match[0]) for match in REGEX_PADDING_CHARACTER.finditer(text) + ] + if len(padding_lengths) < 1: + return text + + spaces_per_tab = min(padding_lengths) + + text = text.replace("\t", " " * spaces_per_tab) + + def _sub_handler(match: re.Match) -> str: + match_length = len(match[0]) + + return pad * math.floor(match_length / spaces_per_tab) + " " * ( + match_length % spaces_per_tab + ) + + return REGEX_PADDING_CHARACTER.sub(_sub_handler, text) + + +def _format_usage_text( + text: str, max_length: int = 60, pad: str = " " +) -> str: + """ + Formats the given usage text for use in the output documentation. + + :param text: The usage text to format. + :param max_length: The maximum length of a line in the formatted output. + :param pad: The string to pad lines with. + + :returns: The formatted usage text. + """ + + # Remove the prefix if it exists + if text.startswith("usage: "): + text = text[7:] + + # Apply text wrapping + result = [] + current_line = [] + current_line_length = 0 + + for token in REGEX_USAGE_TOKEN.finditer(text): + token_len = len(token[0]) + + # We've exceeded the maximum length, start a new line + if current_line_length + len(token[0]) > max_length: + result.append(current_line) + current_line = [] + current_line_length = 0 + + current_line.append(token[0]) + current_line_length += token_len + + # If the line has not already been appended, add it now + if len(current_line) > 0: + result.append(current_line) + + return "\n".join( + [ + (pad * (1 if line > 0 else 0)) + " ".join(entries) + for line, entries in enumerate(result) + ] + ) + + +def __markdown_to_rst_sub_handler(match: re.Match) -> str: + link = match["link"] + if link.startswith("/"): + link = f"https://linode.com{link}" + + return f"`{match['text']} <{link}>`_" + + +# TODO: Unify this with the markdown logic under a new `parsing` package. +def _markdown_to_rst(markdown_text: str) -> str: + """ + Translates the given Markdown text into its RST equivalent. + + :param markdown_text: The Markdown text to translate. + + :returns: The translated text. + """ + result = REGEX_MARKDOWN_LINK.sub( + __markdown_to_rst_sub_handler, markdown_text + ) + + result = REGEX_MARKDOWN_CODE_TAG.sub( + lambda match: f"``{match['text']}``", result + ) + + return result + + +def _format_type( + data_type: str, + item_type: Optional[str] = None, + _format: Optional[str] = None, +) -> str: + """ + Returns the formatted string for the given data and item types. + + :param data_type: The root type of an argument/attribute. + :param item_type: The type of each item in an argument/attribute, if applicable. + :param _format: The `format` attribute of an argument/attribute. + + :returns: The formatted type string, + """ + + if _format == "json" or data_type == "object": + return "json" + + if data_type == "array": + if item_type is None: + raise ValueError( + "item_type must be defined when data_type is defined" + ) + + item_type_fmt = OPENAPI_TYPE_FMT_TRANSLATION.get(item_type) + if item_type_fmt is None: + raise ValueError(f"Unknown item type: {item_type}") + + return f"[]{item_type_fmt}" + + type_fmt = OPENAPI_TYPE_FMT_TRANSLATION.get(data_type) + if type_fmt is None: + raise ValueError(f"Unknown data type: {data_type}") + + return type_fmt diff --git a/linodecli/documentation/templates/_macros.rst.j2 b/linodecli/documentation/templates/_macros.rst.j2 new file mode 100644 index 000000000..9325eb844 --- /dev/null +++ b/linodecli/documentation/templates/_macros.rst.j2 @@ -0,0 +1,29 @@ +{% macro group_action_subheading(title, description) %} + +.. rst-class:: action-subheading + +{{ title }} + +.. rst-class:: action-subheading-description + +{{ description }} + +{% endmacro %} + +{% macro group_action_table(class, widths) %} + +.. rst-class:: {{ class }} + +.. list-table:: + :header-rows: 1 + :width: 100% + :widths: {{ widths | join(" ") }} + +{% for row in varargs %} +{% for cell in row %} +{{ "%s - %s" | format("*" if loop.index0 == 0 else " ", cell.split("\n") | join("\\n")) | indent(first=True) }} +{% endfor %} + +{% endfor %} + +{% endmacro %} diff --git a/linodecli/documentation/templates/_roles.rst.j2 b/linodecli/documentation/templates/_roles.rst.j2 new file mode 100644 index 000000000..abf786ae5 --- /dev/null +++ b/linodecli/documentation/templates/_roles.rst.j2 @@ -0,0 +1,11 @@ +.. role:: action-keyword-key +.. role:: action-keyword-values +.. role:: action-table-field-name +.. role:: action-table-field-optional +.. role:: action-table-field-required +.. role:: action-table-field-type +.. role:: action-table-field-example +.. role:: action-argument-additional-details + +.. role:: json(code) + :language: JSON diff --git a/linodecli/documentation/templates/group.rst.j2 b/linodecli/documentation/templates/group.rst.j2 new file mode 100644 index 000000000..5b7f6e33f --- /dev/null +++ b/linodecli/documentation/templates/group.rst.j2 @@ -0,0 +1,14 @@ +{% import "group_action.rst.j2" as action_tmpl %} +{{ pretty_name }} +{{ "=" * (pretty_name | length) }} + +This section details {{ pretty_name[:-1] if pretty_name[-1] == "s" else pretty_name }}-related Linode CLI commands. + +{% for action in actions %} +{{ action_tmpl.render(action) }} + +{% if loop.index < (actions | length) - 1 %} +------------ +{% endif %} + +{% endfor %} diff --git a/linodecli/documentation/templates/group_action.rst.j2 b/linodecli/documentation/templates/group_action.rst.j2 new file mode 100644 index 000000000..3a8caee84 --- /dev/null +++ b/linodecli/documentation/templates/group_action.rst.j2 @@ -0,0 +1,61 @@ +{% macro render(action) %} +{% include "_roles.rst.j2" %} +{% import "group_action_params.rst.j2" as params_tmpl %} +{% import "group_action_usage.rst.j2" as usage_tmpl %} +{% import "group_action_samples.rst.j2" as samples_tmpl %} +{% import "group_action_arguments.rst.j2" as args_tmpl %} +{% import "group_action_filterable.rst.j2" as filterable_tmpl %} +{% import "group_action_attributes.rst.j2" as attrs_tmpl %} + +{% set action_title = action.action[0] %} +{% set action_title_format = "`%s <%s>`_" % (action_title, action.api_documentation_url) if action.api_documentation_url else action_title %} +{% set has_aliases = action.action | length > 1 %} +{% if has_aliases %} +.. rst-class:: action-has-keywords +{% endif %} + +.. _commands_{{ action.command }}_{{ action.action[0] }}: + +{{ action_title_format }} +{{ "-" * (action_title_format | length) }} +{% if has_aliases %} + +.. rst-class:: action-keyword + +:action-keyword-key:`Aliases:` +{% for alias in action.action[1:] %}:action-keyword-values:`{{ alias }}`{% if not loop.last %}, {% endif %}{% endfor %} + +{% endif %} + +{% if action.deprecated %} +.. warning:: + This command is deprecated and may not be supported in the future. +{% endif %} + +{{ action.description }} + +{% if action.usage %} +{{ usage_tmpl.render(action.usage) }} +{% endif %} + +{% if action.samples | length > 0 %} +{{ samples_tmpl.render(action.samples) }} +{% endif %} + +{% if action.parameters | length > 0 %} +{{ params_tmpl.render(action.parameters) }} +{% endif %} + +{% if action.argument_sections | length > 0 %} +{{ args_tmpl.render(action.command, action.action[0], action.argument_sections, action.argument_sections_names) }} +{% endif %} + +{% if action.filterable_attributes | length > 0 %} +{{ filterable_tmpl.render(action.filterable_attributes) }} +{% endif %} + +{% if action.attribute_sections | length > 0%} +{{ attrs_tmpl.render(action.command, action.action[0], action.attribute_sections, action.attribute_sections_names) }} +{% endif %} + +{% endmacro %} diff --git a/linodecli/documentation/templates/group_action_arguments.rst.j2 b/linodecli/documentation/templates/group_action_arguments.rst.j2 new file mode 100644 index 000000000..88627c3f0 --- /dev/null +++ b/linodecli/documentation/templates/group_action_arguments.rst.j2 @@ -0,0 +1,55 @@ +{% macro render(command, action, sections, section_names) %} +{% include "_roles.rst.j2" %} +{% import "_macros.rst.j2" as macros %} + +{{ + macros.group_action_subheading( + "Arguments", + "Additional fields used to execute this request." + ) +}} + +{% for section in sections %} +{% if section.name | length > 0 %} + +.. _commands_{{ command }}_{{ action }}_argument_sections_{{ section.name }}: + +.. rst-class:: action-section-header + +{{ section.name }} + +{% endif %} + +{% set rows = [] %} +{% for arg in section.entries %} + {% set description = arg.description if arg.description else "N/A" %} + {% if arg.additional_details | length > 0 %} + {% set description = ":action-argument-additional-details:`(%s)` %s" | format(arg.additional_details | join(", "), description) %} + {% endif %} + {% + do rows.append( + [ + ":action-table-field-name:`\-\-%s`%s%s" % ( + arg.path, + " :action-table-field-required:`*`" if arg.required else "", + " :ref:`(section) `" % (command, action, arg.path) + if arg.is_parent and arg.path in section_names else "", + ), + ":action-table-field-type:`%s`" % arg.type, + ":action-table-field-example:`%s`" % ((arg.example | truncate_middle(length=25)) if arg.example else "N/A"), + description + ] + ) + %} +{% endfor %} +{{ + macros.group_action_table( + "action-argument-section-table", + [1, 1, 1, 97], + ["Name", "Type", "Example", "Description"], + *rows + ) +}} +{% endfor %} + +{% endmacro %} diff --git a/linodecli/documentation/templates/group_action_attributes.rst.j2 b/linodecli/documentation/templates/group_action_attributes.rst.j2 new file mode 100644 index 000000000..c6d935811 --- /dev/null +++ b/linodecli/documentation/templates/group_action_attributes.rst.j2 @@ -0,0 +1,49 @@ +{% macro render(command, action, sections, section_names) %} +{% include "_roles.rst.j2" %} +{% import "_macros.rst.j2" as macros %} + +{{ + macros.group_action_subheading( + "Result Attributes", + "The attributes returned by this command." + ) +}} + +{% for section in sections %} +{% if section.name | length > 0 %} + +.. _commands_{{ command }}_{{ action }}_attribute_sections_{{ section.name }}: + +.. rst-class:: action-section-header + +{{ section.name }} + +{% endif %} +{% set rows = [] %} +{% for attr in section.entries %} + {% + do rows.append( + [ + ":action-table-field-name:`%s` %s" % ( + attr.name, + ":ref:`(section) `" % (command, action, attr.name) + if attr.is_parent and attr.path in section_names else "" + ), + ":action-table-field-type:`%s`" % attr.type, + ":action-table-field-example:`%s`" % ((attr.example | truncate_middle(length=25)) if attr.example else "N/A"), + attr.description if attr.description else "N/A" + ] + ) + %} +{% endfor %} +{{ + macros.group_action_table( + "action-attribute-section-table", + [1, 1, 1, 97], + ["Name", "Type", "Example", "Description"], + *rows + ) +}} +{% endfor %} + +{% endmacro %} diff --git a/linodecli/documentation/templates/group_action_filterable.rst.j2 b/linodecli/documentation/templates/group_action_filterable.rst.j2 new file mode 100644 index 000000000..77ebf4fd0 --- /dev/null +++ b/linodecli/documentation/templates/group_action_filterable.rst.j2 @@ -0,0 +1,33 @@ +{% macro render(attributes) %} +{% include "_roles.rst.j2" %} +{% import "_macros.rst.j2" as macros %} + +{{ + macros.group_action_subheading( + "Filterable Attributes", + "Arguments used to define a filter for response entries." + ) +}} + +{% set rows = [] %} +{% for attr in attributes %} + {% + do rows.append( + [ + ":action-table-field-name:`\-\-%s`" % attr.name, + ":action-table-field-type:`%s`" % attr.type, + attr.description if attr.description else "N/A" + ] + ) + %} +{% endfor %} +{{ + macros.group_action_table( + "action-filterable-field-table", + [1, 1, 98], + ["Name", "Type", "Description"], + *rows + ) +}} + +{% endmacro %} diff --git a/linodecli/documentation/templates/group_action_params.rst.j2 b/linodecli/documentation/templates/group_action_params.rst.j2 new file mode 100644 index 000000000..39aa1dac2 --- /dev/null +++ b/linodecli/documentation/templates/group_action_params.rst.j2 @@ -0,0 +1,33 @@ +{% macro render(params) %} +{% include "_roles.rst.j2" %} +{% import "_macros.rst.j2" as macros %} + +{{ + macros.group_action_subheading( + "Parameters", + "Positional parameters used to define the resource this command should target." + ) +}} + +{% set rows = [] %} +{% for param in params %} + {% + do rows.append( + [ + ":action-table-field-name:`%s`" % param.name, + ":action-table-field-type:`%s`" % param.type, + param.description if param.description else "N/A" + ] + ) + %} +{% endfor %} +{{ + macros.group_action_table( + "action-parameter-table", + [1, 1, 98], + ["Name", "Type", "Description"], + *rows + ) +}} + +{% endmacro %} diff --git a/linodecli/documentation/templates/group_action_samples.rst.j2 b/linodecli/documentation/templates/group_action_samples.rst.j2 new file mode 100644 index 000000000..a1f768607 --- /dev/null +++ b/linodecli/documentation/templates/group_action_samples.rst.j2 @@ -0,0 +1,20 @@ +{% macro render(samples) %} +{% include "_roles.rst.j2" %} +{% import "_macros.rst.j2" as macros %} + +{{ + macros.group_action_subheading( + "Sample%s" | format("s" if samples | length > 1 else ""), + "Examples of how this command might be used." + ) +}} + +{% for sample in samples %} + +.. code-block:: bash + +{{ sample | indent(first=True) }} + +{% endfor %} + +{% endmacro %} diff --git a/linodecli/documentation/templates/group_action_usage.rst.j2 b/linodecli/documentation/templates/group_action_usage.rst.j2 new file mode 100644 index 000000000..9649702a1 --- /dev/null +++ b/linodecli/documentation/templates/group_action_usage.rst.j2 @@ -0,0 +1,16 @@ +{% macro render(usage) %} +{% include "_roles.rst.j2" %} +{% import "_macros.rst.j2" as macros %} + +{{ + macros.group_action_subheading( + "Usage", + "The format accepted by this command." + ) +}} + +.. code-block:: bash + +{{ usage | indent(first=True) }} + +{% endmacro %} diff --git a/linodecli/helpers.py b/linodecli/helpers.py index f94096194..141b7f9c4 100644 --- a/linodecli/helpers.py +++ b/linodecli/helpers.py @@ -6,7 +6,7 @@ import os from argparse import ArgumentParser from pathlib import Path -from typing import Optional +from typing import Any, Callable, List, Optional, TypeVar from urllib.parse import urlparse API_HOST_OVERRIDE = os.getenv("LINODE_CLI_API_HOST") @@ -18,6 +18,9 @@ # no path is specified. API_CA_PATH = os.getenv("LINODE_CLI_CA", None) or True +# Keywords used to infer whether an operation is a CRUD operation. +CRUD_OPERATION_KEYWORDS = ["list", "view", "create", "add", "update", "delete"] + def handle_url_overrides( url: str, @@ -120,3 +123,35 @@ def expand_globs(pattern: str): print(f"No file found matching pattern {pattern}") return [Path(x).resolve() for x in results] + + +T = TypeVar("T") + + +def sorted_actions_smart( + actions: List[T], key: Callable[[T], str] = lambda v: v +) -> List[T]: + """ + Returns the given list of actions ordered to maximize readability. + + :param actions: The actions to order. + :param key: A function to retrieve the name of a given action. + + :returns: The ordered actions. + """ + + def __key(action: T) -> Any: + name = key(action) + + # Prioritize CRUD operations + for i, crud_operation in enumerate(CRUD_OPERATION_KEYWORDS): + name = name.replace(crud_operation, str(i)) + + return ( + # Prioritize root operations + "-" in name, + # Sort everything else + name, + ) + + return sorted(actions, key=__key) diff --git a/linodecli/plugins/README.md b/linodecli/plugins/README.md deleted file mode 100644 index 9dc12151c..000000000 --- a/linodecli/plugins/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# Plugin support - -The Linode CLI supports embedded plugins, features that are hard-coded (instead -of generated as the rest of the CLI is) but are accessible directly through the -CLI as other features are. All plugins are found in this directory. - -## Creating a Plugin - -To create a plugin, simply drop a new python file into this directory or write a -Python module that presents the interface described below. If the -plugin is a Python module, make sure the `call` method is in the `__init__.py` -file in the root of the module. - -Plugins in this directory are called "Internal Plugins," and must meet the -following conditions: - -* Its name must be unique, both with the other plugins and with all commands - offered through the generated CLI -* Its name must not contain special characters, and should be easily to enter - on the command line -* It must contain a `call(args, context)` function for invocation -* It must support a `--help` command as all other CLI commands do. - -Plugins that are installed separately and registered with the `register-plugin` -command are called "Third Party Plugins," and must meet the following -conditions: - -* Its name must be unique, both with the internal plugins and all CLI operations -* It must contain a `call(args, context)` function for invocation -* It must contain a `PLUGIN_NAME` constant whose value is a string that does not - contain special characters, and should be easy to enter on the command line. -* It should support a `--help` command as all other CLI commands do. - -## The Plugin Interface - -All plugins are either an individual python file or a Python module -that reside in this directory or installed separately. Plugins must have one function, `call`, that -matches the following signature: - -```python -def call(args, context): - """ - This is the function used to invoke the plugin. It will receive the remainder - of sys.argv after the plugin's name, and a context of user defaults and config - settings. - """ -``` - -### The PluginContext - -The `PluginContext` class, passed as `context` to the `call` function, includes -all information the plugin is given during invocation. This includes the following: - -* `token` - The Personal Access Token registered with the CLI to make requests -* `client` - The CLI Client object that can make authenticated requests on behalf - of the acting user. This is preferrable to using `requests` or another library - directly (see below). - -#### CLI Client - -The CLI Client provided as `context.client` can make authenticated API calls on -behalf of the user using the provided `call_operation` method. This method is -invoked with a command and an action, and executes the given CLI command as if -it were entered into the command line, returning the resulting status code and -JSON data. - -## Configuration - -Plugins can access the CLI's configuration through the CLI Client mentioned above. -Plugins are allowed to: - -* Read values from the current user's config -* Read and write their own values to the current user's config - -Any other operation is not supported and may break without notice. - -### Methods - -The `Configuration` class provides the following methods for plugins to use: - -**get_value(key)** Returns the value the current user has set for this key, or `None` -if the key does not exist. Currently supported keys are `region`, `type`, and `image`. - -**plugin_set_value(key, value)** Sets a value in the user's config for this plugin. -Plugins can safely set values for any key, and they are namespaced away from other -config keys. - -**plugin_get_value(key)** Returns the value this plugin previously set for the given -key, or `None` if not set. Plugins should assume they are not configured if they -receive `None` when getting a value with this method. - -**write_config()** Writes config changes to disk. This is required to save changes -after calling `plugin_set_value` above. - -### Sample Code - -The following code manipulates and reads from the config in a plugin: - -```python - -def call(args, context): - # get a value from the user's config - default_region = context.client.config.get_value('region') - - # check if we set a value previously - our_value = context.client.config.plugin_get_value('configured') - - if our_value is None: - # plugin not configured - do configuration here - context.client.config.plugin_set_value('configured', 'yes') - - # save the config so changes take effect - context.client.config.write_config() - - # normal plugin code -``` - -## Development - -To develop a plugin, simply create a python source file in this directory that -has a `call` function as described above. To test, simply build the CLI as -normal (via `make install`) or simply by running `./setup.py install` in the -root directory of the project (this installs the code without generating new -baked data, and will only work if you've installed the CLI via `make install` -at least once, however it's a lot faster). - -To develop a third party plugin, simply create and install your module and register -it to the CLI. As long as the `PLUGIN_NAME` doesn't change, updated installations -should invoke the new code. - -### Examples - -This directory contains two example plugins, `echo.py.example` and -`regionstats.py.example`. To run these, simply remove the `.example` at the end -of the file and build the CLI as described above. - -[This directory](https://github.com/linode/linode-cli/tree/main/examples/third-party-plugin) -contains an example Third Party Plugin module. This module is installable and -can be registered to the CLI. diff --git a/pyproject.toml b/pyproject.toml index 311402da7..577520f2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ dev = [ "boto3-stubs[s3]", "build>=0.10.0", "twine>=4.0.2", + "jinja2", + "sphinx_rtd_theme", "pytest-rerunfailures" ] @@ -42,12 +44,18 @@ linode-cli = "linodecli:main" linode = "linodecli:main" lin = "linodecli:main" +[tool.setuptools] +include-package-data = true + [tool.setuptools.dynamic] version = { attr = "linodecli.version.__version__" } [tool.setuptools.packages.find] include = ["linodecli*"] +[tool.setuptools.package-data] +"linodecli.documentation.templates" = ["*"] + [tool.isort] profile = "black" line_length = 80 diff --git a/tests/fixtures/docs_template_test.yaml b/tests/fixtures/docs_template_test.yaml new file mode 100644 index 000000000..b97c85a54 --- /dev/null +++ b/tests/fixtures/docs_template_test.yaml @@ -0,0 +1,242 @@ +openapi: 3.0.1 +info: + title: API Specification + version: 1.0.0 +servers: + - url: https://api.linode.com/v4 + +paths: + /{apiVersion}/test/resource: + x-linode-cli-command: test-resource + parameters: + - name: "apiVersion" + in: "path" + required: true + schema: + enum: [ "v4", "v4beta" ] + type: "string" + default: "v4" + post: + summary: Create a new test resource. + operationId: testResourceCreate + description: Create a new test resource. + x-linode-cli-action: create + externalDocs: + url: https://linode.com + requestBody: + description: > + The parameters to set when creating the test resource. + required: True + content: + application/json: + schema: + required: + - boolean_field + - string_field + allOf: + - $ref: '#/components/schemas/TestResource' + responses: + '200': + description: The new test resource. + content: + application/json: + schema: + $ref: '#/components/schemas/TestResource' + get: + summary: List test resources. + operationId: testResourceList + description: List test resources. + externalDocs: + url: https://linode.com + x-linode-cli-action: + - list + - ls + responses: + '200': + description: A paginated list containing the test resources. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/TestResource' + page: + $ref: '#/components/schemas/PaginationEnvelope/properties/page' + pages: + $ref: '#/components/schemas/PaginationEnvelope/properties/pages' + results: + $ref: '#/components/schemas/PaginationEnvelope/properties/results' + + /{apiVersion}/test/resource/{resourceId}: + x-linode-cli-command: test-resource + parameters: + - name: "apiVersion" + in: "path" + required: true + schema: + enum: [ "v4", "v4beta" ] + type: "string" + default: "v4" + + - name: "resourceId" + required: true + in: "path" + description: "The ID of the resource." + schema: + type: "integer" + put: + summary: Update a test resource. + operationId: testResourcePut + description: Update a test resource. + externalDocs: + url: https://linode.com + x-linode-cli-action: update + requestBody: + description: > + The parameters to set when creating the test resource. + required: True + content: + application/json: + schema: + $ref: '#/components/schemas/TestResource' + responses: + '200': + description: The updated test resource. + content: + application/json: + schema: + $ref: '#/components/schemas/TestResource' + + get: + summary: Get information about a test resource. + operationId: testResourceGet + description: Get information about a test resource. + externalDocs: + url: https://linode.com + x-linode-cli-action: view + responses: + '200': + description: The test resource. + content: + application/json: + schema: + $ref: '#/components/schemas/TestResource' + +components: + schemas: + PaginationEnvelope: + type: object + properties: + pages: + type: integer + readOnly: true + description: The total number of pages. + example: 1 + page: + type: integer + readOnly: true + description: The current page. + example: 1 + results: + type: integer + readOnly: true + description: The total number of results. + example: 1 + TestResource: + type: object + description: Foobar object request + properties: + resource_id: + type: integer + readOnly: true + description: "The ID of this test resource." + example: 123 + string_field: + type: string + description: "An arbitrary string." + nullable: true + example: "test string" + integer_field: + type: integer + description: "An arbitrary integer." + writeOnly: true + boolean_field: + type: boolean + description: "An arbitrary boolean." + example: true + x-linode-filterable: true + object_field: + type: object + description: "An arbitrary object." + required: + - bar + properties: + foo: + type: string + description: "An arbitrary foo." + example: "bar" + bar: + type: string + description: "An arbitrary bar." + example: "foo" + literal_list: + type: array + description: "An arbitrary list of literals" + example: + - "foo" + - "bar" + items: + type: string + object_list: + type: array + description: "An arbitrary list of objects." + items: + type: object + description: An arbitrary object. + required: + - field_integer + properties: + field_string: + type: string + description: An arbitrary nested string. + example: "foobar" + field_integer: + type: integer + description: An arbitrary nested integer. + example: 321 + + +# nullable_int: +# type: integer +# nullable: true +# description: An arbitrary nullable int +# nullable_string: +# type: string +# nullable: true +# description: An arbitrary nullable string +# nullable_float: +# type: number +# nullable: true +# description: An arbitrary nullable float +# +# nested_int: +# type: number +# description: A deeply nested integer. +# field_array: +# type: array +# description: An arbitrary deeply nested array. +# items: +# type: string +# field_string: +# type: string +# description: An arbitrary field. +# field_int: +# type: number +# description: An arbitrary field. +# nullable_string: +# type: string +# description: An arbitrary nullable string. +# nullable: true diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index fef49ab27..6ef6fc77d 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,5 +1,6 @@ import configparser -from typing import List +from io import BytesIO +from typing import Callable, Iterable, List, Optional, TypeVar import pytest from openapi3 import OpenAPI @@ -324,6 +325,38 @@ def write_config(self): # pylint: disable=missing-function-docstring return Config() +@pytest.fixture +def build_mock_cli_with_spec(mock_cli): + """ + Returns a CLI object initialized with the OpenAPI spec fixture with the given name. + """ + + def _inner(spec_name: str): + result = mock_cli + result.ops = {} + + # Buffer the output to prevent overwriting the local data file + baked_buffer = BytesIO() + + result.bake(spec=_get_parsed_yaml(spec_name), file=baked_buffer) + + # Rewind the baked buffer for read + baked_buffer.seek(0) + + result.load_baked(file=baked_buffer) + + return result + + return _inner + + +T = TypeVar("T") + + +def get_first(data: Iterable[T], search: Callable[[T], bool]) -> Optional[T]: + return next((entry for entry in data if search(entry)), None) + + def assert_contains_ordered_substrings(target: str, entries: List[str]): """ Asserts whether the given string contains the given entries in order, diff --git a/tests/unit/documentation/test_filters.py b/tests/unit/documentation/test_filters.py new file mode 100644 index 000000000..a528bbe53 --- /dev/null +++ b/tests/unit/documentation/test_filters.py @@ -0,0 +1,11 @@ +from linodecli.documentation.filters import _filter_truncate_middle + + +class TestDocumentationFilters: + def test_truncation_middle(self): + assert ( + _filter_truncate_middle("foobarfoobar", length=12) == "foobarfoobar" + ) + assert _filter_truncate_middle("foobarfoobar", length=6) == "foo...bar" + assert _filter_truncate_middle("foobarfoobar", length=2) == "f...r" + assert _filter_truncate_middle("foobarodd", length=5) == "foo...dd" diff --git a/tests/unit/documentation/test_template.py b/tests/unit/documentation/test_template.py new file mode 100644 index 000000000..6366e5554 --- /dev/null +++ b/tests/unit/documentation/test_template.py @@ -0,0 +1,374 @@ +import pytest + +from linodecli.documentation.template_data import ( + Action, + Argument, + Param, + ResponseAttribute, + Root, +) +from tests.unit.conftest import get_first + + +class TestDocumentationTemplate: + @pytest.fixture + def mock_cli(self, build_mock_cli_with_spec): + return build_mock_cli_with_spec("docs_template_test.yaml") + + @pytest.fixture + def mock_cli_with_parsed_template(self, mock_cli): + template_data = Root.from_cli(mock_cli) + return mock_cli, template_data + + def test_data_group(self, mock_cli_with_parsed_template): + _, tmpl_data = mock_cli_with_parsed_template + + assert len(tmpl_data.groups) == 1 + + group = get_first(tmpl_data.groups, lambda v: v.name == "test-resource") + assert group.pretty_name == "Test Resource" + assert len(group.actions) == 4 + + def test_data_action_view(self, mock_cli_with_parsed_template): + cli, tmpl_data = mock_cli_with_parsed_template + + group = get_first(tmpl_data.groups, lambda v: v.name == "test-resource") + action = get_first(group.actions, lambda v: v.action[0] == "view") + + assert action.command == "test-resource" + assert action.action == ["view"] + + assert "linode-cli test-resource view [-h]" in action.usage + assert "resourceId" in action.usage + + assert "Get information about a test resource." == action.summary + assert "Get information about a test resource." == action.description + assert "https://linode.com" == action.api_documentation_url + assert not action.deprecated + + assert len(action.argument_sections) == 0 + assert len(action.filterable_attributes) == 0 + + self._validate_resource_parameters(action) + self._validate_resource_response_attributes(action) + + def test_data_action_list(self, mock_cli_with_parsed_template): + cli, tmpl_data = mock_cli_with_parsed_template + + group = get_first(tmpl_data.groups, lambda v: v.name == "test-resource") + action = get_first(group.actions, lambda v: v.action[0] == "list") + + assert action.command == "test-resource" + assert action.action == ["list", "ls"] + + assert "linode-cli test-resource list [-h]" in action.usage + + assert "List test resources." == action.summary + assert "List test resources." == action.description + assert "https://linode.com" == action.api_documentation_url + assert not action.deprecated + + assert len(action.argument_sections) == 0 + assert len(action.parameters) == 0 + + assert action.filterable_attributes == [ + ResponseAttribute( + name="boolean_field", + type="bool", + description="An arbitrary boolean.", + example="true", + ) + ] + + self._validate_resource_response_attributes(action) + + def test_data_action_create(self, mock_cli_with_parsed_template): + cli, tmpl_data = mock_cli_with_parsed_template + + group = get_first(tmpl_data.groups, lambda v: v.name == "test-resource") + action = get_first(group.actions, lambda v: v.action[0] == "create") + + assert action.command == "test-resource" + assert action.action == ["create"] + + assert "linode-cli test-resource create [-h]" in action.usage + assert "Create a new test resource." == action.summary + assert "Create a new test resource." == action.description + assert "https://linode.com" == action.api_documentation_url + assert not action.deprecated + + assert len(action.parameters) == 0 + assert len(action.samples) == 0 + assert len(action.filterable_attributes) == 0 + + self._validate_resource_response_attributes(action) + + arg_sections = action.argument_sections + assert len(arg_sections) == 2 + + assert arg_sections[0].name == "" + assert arg_sections[0].entries == [ + Argument( + path="boolean_field", + required=True, + type="bool", + description="An arbitrary boolean.", + example="true", + ), + Argument( + path="string_field", + required=True, + type="str", + description="An arbitrary string.", + example="test string", + additional_details=["nullable"], + ), + Argument( + path="object_field.bar", + required=True, + type="str", + description="An arbitrary bar.", + example="foo", + ), + Argument( + path="integer_field", + required=False, + type="int", + description="An arbitrary integer.", + additional_details=["write-only"], + ), + Argument( + path="literal_list", + required=False, + type="[]str", + description="An arbitrary list of literals.", + example="foo", + ), + Argument( + path="object_list", + required=False, + type="json", + is_json=True, + description="An arbitrary object.", + is_parent=True, + ), + Argument( + path="object_field.foo", + required=False, + type="str", + description="An arbitrary foo.", + example="bar", + ), + ] + + assert arg_sections[1].name == "object_list" + assert arg_sections[1].entries == [ + Argument( + path="object_list.field_integer", + required=True, + type="int", + description="An arbitrary nested integer.", + example="321", + is_child=True, + depth=1, + parent="object_list", + ), + Argument( + path="object_list.field_string", + required=False, + type="str", + description="An arbitrary nested string.", + example="foobar", + is_child=True, + depth=1, + parent="object_list", + ), + ] + + def test_data_action_put(self, mock_cli_with_parsed_template): + cli, tmpl_data = mock_cli_with_parsed_template + + group = get_first(tmpl_data.groups, lambda v: v.name == "test-resource") + action = get_first(group.actions, lambda v: v.action[0] == "update") + + assert action.command == "test-resource" + assert action.action == ["update"] + + assert "linode-cli test-resource update [-h]" in action.usage + assert "resourceId" in action.usage + + assert "Update a test resource." == action.summary + assert "Update a test resource." == action.description + assert "https://linode.com" == action.api_documentation_url + assert not action.deprecated + + assert len(action.samples) == 0 + assert len(action.filterable_attributes) == 0 + + self._validate_resource_parameters(action) + self._validate_resource_response_attributes(action) + + arg_sections = action.argument_sections + assert len(arg_sections) == 2 + + assert arg_sections[0].name == "" + assert arg_sections[0].entries == [ + Argument( + path="object_field.bar", + required=True, + type="str", + description="An arbitrary bar.", + example="foo", + ), + Argument( + path="boolean_field", + required=False, + type="bool", + description="An arbitrary boolean.", + example="true", + ), + Argument( + path="integer_field", + required=False, + type="int", + description="An arbitrary integer.", + additional_details=["write-only"], + ), + Argument( + path="literal_list", + required=False, + type="[]str", + description="An arbitrary list of literals.", + example="foo", + ), + Argument( + path="object_list", + required=False, + type="json", + is_json=True, + description="An arbitrary object.", + is_parent=True, + ), + Argument( + path="string_field", + required=False, + type="str", + description="An arbitrary string.", + example="test string", + additional_details=["nullable"], + ), + Argument( + path="object_field.foo", + required=False, + type="str", + description="An arbitrary foo.", + example="bar", + ), + ] + + assert arg_sections[1].name == "object_list" + assert arg_sections[1].entries == [ + Argument( + path="object_list.field_integer", + required=True, + type="int", + description="An arbitrary nested integer.", + example="321", + is_child=True, + depth=1, + parent="object_list", + ), + Argument( + path="object_list.field_string", + required=False, + type="str", + description="An arbitrary nested string.", + example="foobar", + is_child=True, + depth=1, + parent="object_list", + ), + ] + + @staticmethod + def _validate_resource_parameters(action: Action): + assert action.parameters == [ + Param( + name="resourceId", + type="int", + description="The ID of the resource.", + ) + ] + + @staticmethod + def _validate_resource_response_attributes( + action: Action, + ): + assert action.attribute_sections_names == { + "", + "object_field", + "object_list", + } + assert len(action.attribute_sections) == 3 + + sections = action.attribute_sections + + assert sections[0].name == "" + assert sections[0].entries == [ + ResponseAttribute( + name="boolean_field", + type="bool", + description="An arbitrary boolean.", + example="true", + ), + ResponseAttribute( + name="literal_list", + type="[]str", + description="An arbitrary list of literals.", + example='["foo", "bar"]', + ), + ResponseAttribute( + name="resource_id", + type="int", + description="The ID of this test resource.", + example="123", + ), + ResponseAttribute( + name="string_field", + type="str", + description="An arbitrary string.", + example="test string", + ), + ] + + assert sections[1].name == "object_field" + assert sections[1].entries == [ + ResponseAttribute( + name="object_field.bar", + type="str", + description="An arbitrary bar.", + example="foo", + ), + ResponseAttribute( + name="object_field.foo", + type="str", + description="An arbitrary foo.", + example="bar", + ), + ] + + assert sections[2].name == "object_list" + assert sections[2].entries == [ + ResponseAttribute( + name="object_list.field_integer", + type="int", + description="An arbitrary nested integer.", + example="321", + ), + ResponseAttribute( + name="object_list.field_string", + type="str", + description="An arbitrary nested string.", + example="foobar", + ), + ] diff --git a/tests/unit/documentation/test_template_util.py b/tests/unit/documentation/test_template_util.py new file mode 100644 index 000000000..015296045 --- /dev/null +++ b/tests/unit/documentation/test_template_util.py @@ -0,0 +1,37 @@ +from linodecli.documentation.template_data.util import ( + _format_type, + _format_usage_text, + _markdown_to_rst, + _normalize_padding, +) + + +class TestDocumentationTemplateUtil: + def test_normalize_padding(self): + assert ( + _normalize_padding("foo\n bar foo\n test\n baz\n wow\n\twow") + == "foo\n bar foo\n test\n baz\n wow\n wow" + ) + + def test_format_usage_text(self): + assert ( + _format_usage_text( + "usage: linode-cli foobar [-h] [--foobarfoobar foobarfoobar] [--bar bar] foobarId", + max_length=28, + ) + == "linode-cli foobar [-h]\n [--foobarfoobar foobarfoobar]\n [--bar bar] foobarId" + ) + + def test_markdown_to_rst(self): + assert ( + _markdown_to_rst("foo [bar](https://linode.com) bar") + == "foo `bar `_ bar" + ) + + def test_format_type(self): + assert _format_type("string") == "str" + assert _format_type("integer") == "int" + assert _format_type("boolean") == "bool" + assert _format_type("number") == "float" + assert _format_type("array", item_type="string") == "[]str" + assert _format_type("object", _format="json") == "json" diff --git a/tests/unit/test_parsing.py b/tests/unit/test_parsing.py index f33bf874c..817a8a7b3 100644 --- a/tests/unit/test_parsing.py +++ b/tests/unit/test_parsing.py @@ -65,7 +65,7 @@ def test_get_first_sentence(self): assert ( get_short_description( - "__Note__. This might be a sentence.\nThis is a sentence." + "__Note__ This might be a sentence.\nThis is a sentence." ) == "This is a sentence." ) diff --git a/wiki/Home.md b/wiki/Home.md deleted file mode 100644 index eb36dd623..000000000 --- a/wiki/Home.md +++ /dev/null @@ -1,4 +0,0 @@ -Welcome to the linode-cli wiki! - -For installation instructions and usage guides, please -refer to the sidebar of this page. \ No newline at end of file diff --git a/wiki/Installation.md b/wiki/Installation.md deleted file mode 100644 index 79db6635a..000000000 --- a/wiki/Installation.md +++ /dev/null @@ -1,69 +0,0 @@ -# Installation - -## PyPi - -```bash -pip3 install linode-cli -# for upgrading -pip3 install linode-cli --upgrade -``` - -## Docker - -### Token -```bash -docker run --rm -it -e LINODE_CLI_TOKEN=$LINODE_TOKEN linode/cli:latest linodes list -``` - -### Config -```bash -docker run --rm -it -v $HOME/.config/linode-cli:/home/cli/.config/linode-cli linode/cli:latest linodes list -``` - -## GitHub Actions - -[Setup Linode CLI](https://github.com/marketplace/actions/setup-linode-cli) GitHub Action to automatically install and authenticate the cli in a GitHub Actions environment: -```yml -- name: Install the Linode CLI - uses: linode/action-linode-cli@v1 - with: - token: ${{ secrets.LINODE_TOKEN }} -``` - -## Community Distributions - -The Linode CLI is available through unofficial channels thanks to our awesome community! These distributions are not included in release testing. - -### Homebrew - -```bash -brew install linode-cli -brew upgrade linode-cli -``` -# Building from Source - -In order to successfully build the CLI, your system will require the following: - -- The `make` command -- `python3` -- `pip3` (to install project dependencies) - -Before attempting a build, install python dependencies like this:: -```bash -make requirements -``` - -Once everything is set up, you can initiate a build like so:: -```bash -make build -``` - -If desired, you may pass in `SPEC=/path/to/openapi-spec` when running `build` -or `install`. This can be a URL or a path to a local spec, and that spec will -be used when generating the CLI. A yaml or json file is accepted. - -To install the package as part of the build process, use this command:: - -```bash -make install -``` diff --git a/wiki/Usage.md b/wiki/Usage.md deleted file mode 100644 index acbbbd4ad..000000000 --- a/wiki/Usage.md +++ /dev/null @@ -1,180 +0,0 @@ -# Usage - -The Linode CLI is invoked with the `linode-cli`. There are two aliases available: `linode` and `lin`. -The CLI accepts two primary arguments, *command* and *action*:: -```bash -linode-cli -``` - -*command* is the part of the CLI you are interacting with, for example "linodes". -You can see a list of all available commands by using `--help`:: -```bash -linode-cli --help -``` - -*action* is the action you want to perform on a given command, for example "list". -You can see a list of all available actions for a command with the `--help` for -that command:: -```bash -linode-cli linodes --help -``` - -Some actions don't require any parameters, but many do. To see details on how -to invoke a specific action, use `--help` for that action:: -```bash -linode-cli linodes create --help -``` - -The first time you invoke the CLI, you will be asked to configure (see -"Configuration" below for details), and optionally select some default values -for "region," "image," and "type." If you configure these defaults, you may -omit them as parameters to actions and the default value will be used. - -## Common Operations - -List Linodes:: -```bash -linode-cli linodes list -``` - -List Linodes in a Region:: -```bash -linode-cli linodes list --region us-east -``` - -Make a Linode:: -```bash -linode-cli linodes create --type g5-standard-2 --region us-east --image linode/debian9 --label cli-1 --root_pass -``` - -Make a Linode using Default Settings:: -```bash -linode-cli linodes create --label cli-2 --root_pass -``` - -Reboot a Linode:: -```bash -linode-cli linodes reboot 12345 -``` - -View available Linode types:: -```bash -linode-cli linodes types -``` - -View your Volumes:: -```bash -linode-cli volumes list -``` - -View your Domains:: -```bash -linode-cli domains list -``` - -View records for a single Domain:: -```bash -linode-cli domains records-list 12345 -``` - -View your user:: -```bash -linode-cli profile view -``` - -## Specifying List Arguments - -When running certain commands, you may need to specify multiple values for a list -argument. This can be done by specifying the argument multiple times for each -value in the list. For example, to create a Linode with multiple `tags` -you can execute the following:: -```bash -linode-cli linodes create --region us-east --type g6-nanode-1 --tags tag1 --tags tag2 -``` - -Lists consisting of nested structures can also be expressed through the command line. -Duplicated attribute will signal a different object. -For example, to create a Linode with a public interface on `eth0` and a VLAN interface -on `eth1` you can execute the following:: -```bash -linode-cli linodes create \ - --region us-east --type g6-nanode-1 --image linode/ubuntu22.04 \ - --root_pass "myr00tp4ss123" \ - # The first interface (index 0) is defined with the public purpose - --interfaces.purpose public \ - # The second interface (index 1) is defined with the vlan purpose. - # The duplicate `interfaces.purpose` here tells the CLI to start building a new interface object. - --interfaces.purpose vlan --interfaces.label my-vlan -``` - -## Specifying Nested Arguments - -When running certain commands, you may need to specify an argument that is nested -in another field. These arguments can be specified using a `.` delimited path to -the argument. For example, to create a firewall with an inbound policy of `DROP` -and an outbound policy of `ACCEPT`, you can execute the following: -```bash -linode-cli firewalls create --label example-firewall --rules.outbound_policy ACCEPT --rules.inbound_policy DROP -``` - -## Special Arguments - -In some cases, certain values for arguments may have unique functionality. - -### Null Values - -Arguments marked as nullable can be passed the value `null` to send an explicit null value to the Linode API: - -```bash -linode-cli networking ip-update --rdns null 127.0.0.1 -``` - -### Empty Lists - -List arguments can be passed the value `[]` to send an explicit empty list value to the Linode API: - -```bash -linode-cli networking ip-share --linode_id 12345 --ips [] -``` - -## Suppressing Defaults - -If you configured default values for `image`, `authorized_users`, `region`, -database `engine`, and Linode `type`, they will be sent for all requests that accept them -if you do not specify a different value. If you want to send a request *without* these -arguments, you must invoke the CLI with the `--no-defaults` option. - -For example, to create a Linode with no `image` after a default Image has been -configured, you would do this:: -```bash -linode-cli linodes create --region us-east --type g5-standard-2 --no-defaults -``` - -## Suppressing Warnings - -In some situations, like when the CLI is out of date, it will generate a warning -in addition to its normal output. If these warnings can interfere with your -scripts or you otherwise want them disabled, simply add the `--suppress-warnings` -flag to prevent them from being emitted. - -## Suppressing Retries - -Sometimes the API responds with a error that can be ignored. For example a timeout -or nginx response that can't be parsed correctly, by default the CLI will retry -calls on these errors we've identified. If you'd like to disable this behavior for -any reason use the ``--no-retry`` flag. - -## Shell Completion - -To generate a completion file for a given shell type, use the `completion` command; -for example to generate completions for bash run:: -```bash -linode-cli completion bash -``` - -The output of this command is suitable to be included in the relevant completion -files to enable command completion on your shell. - -This command currently supports completions bash and fish shells. - -Use `bashcompinit` on zsh with the bash completions for support on zsh shells. diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md deleted file mode 100644 index a22828883..000000000 --- a/wiki/_Sidebar.md +++ /dev/null @@ -1,10 +0,0 @@ -- [Installation](./Installation) -- [Configuration](./Configuration) -- [Usage](./Usage) -- [Output](./Output) -- [Plugins](./Plugins) -- [Development](./Development%20-%20Index) - - [Overview](./Development%20-%20Overview) - - [Skeleton](./Development%20-%20Skeleton) - - [Setup](./Development%20-%20Setup) - - [Testing](./Development%20-%20Testing) \ No newline at end of file diff --git a/wiki/development/Development - Index.md b/wiki/development/Development - Index.md deleted file mode 100644 index 291141a1d..000000000 --- a/wiki/development/Development - Index.md +++ /dev/null @@ -1,12 +0,0 @@ -This guide will help you get started developing against and contributing to the Linode CLI. - -## Index - -1. [Overview](./Development%20-%20Overview) -2. [Skeleton](./Development%20-%20Skeleton) -3. [Setup](./Development%20-%20Setup) -4. [Testing](./Development%20-%20Testing) - -## Contributing - -Once you're ready to contribute a change to the project, please refer to our [Contributing Guide](https://github.com/linode/linode-cli/blob/dev/CONTRIBUTING.md). \ No newline at end of file diff --git a/wiki/development/Development - Overview.md b/wiki/development/Development - Overview.md deleted file mode 100644 index 78cc95d47..000000000 --- a/wiki/development/Development - Overview.md +++ /dev/null @@ -1,104 +0,0 @@ -The following section outlines the core functions of the Linode CLI. - -## OpenAPI Specification Parsing - -Most Linode CLI commands (excluding [plugin commands](https://github.com/linode/linode-cli/tree/dev/linodecli/plugins)) -are generated dynamically at build-time from the [Linode OpenAPI Specification](https://github.com/linode/linode-api-docs), -which is also used to generate the [official Linode API documentation](https://www.linode.com/docs/api/). - -Each OpenAPI spec endpoint method is parsed into an `OpenAPIOperation` object. -This object includes all necessary request and response arguments to create a command, -stored as `OpenAPIRequestArg` and `OpenAPIResponseAttr` objects respectively. -At runtime, the Linode CLI changes each `OpenAPIRequestArg` to an argparse argument and -each `OpenAPIResponseAttr` to an outputtable column. It can also manage complex structures like -nested objects and lists, resulting in commands and outputs that may not -exactly match the OpenAPI specification. - -## OpenAPI Specification Extensions - -In order to better support the Linode CLI, the following [Specification Extensions](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#specificationExtensions) have been added to Linode's OpenAPI spec: - -| Attribute | Location | Purpose | -| --- | --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| x-linode-cli-action | method | The action name for operations under this path. If not present, operationId is used. | -| x-linode-cli-color | property | If present, defines key-value pairs of property value: color. Colors must be one of the [standard colors](https://rich.readthedocs.io/en/stable/appendix/colors.html#appendix-colors) that accepted by Rich. Must include a default. | -| x-linode-cli-command | path | The command name for operations under this path. If not present, "default" is used. | -| x-linode-cli-display | property | If truthy, displays this as a column in output. If a number, determines the ordering (left to right). | -| x-linode-cli-format | property | Overrides the "format" given in this property for the CLI only. Valid values are `file` and `json`. | -| x-linode-cli-skip | path | If present and truthy, this method will not be available in the CLI. | -| x-linode-cli-allowed-defaults| requestBody | Tells the CLI what configured defaults apply to this request. Valid defaults are "region", "image", "authorized_users", "engine", and "type". | -| x-linode-cli-nested-list | content-type| Tells the CLI to flatten a single object into multiple table rows based on the keys included in this value. Values should be comma-delimited JSON paths, and must all be present on response objects. When used, a new key `_split` is added to each flattened object whose value is the last segment of the JSON path used to generate the flattened object from the source. | -| x-linode-cli-use-schema | content-type| Overrides the normal schema for the object and uses this instead. Especially useful when paired with ``x-linode-cli-nested-list``, allowing a schema to describe the flattened object instead of the original object. | -| x-linode-cli-subtables | content-type| Indicates that certain response attributes should be printed in a separate "sub"-table. This allows certain endpoints with nested structures in the response to be displayed correctly. | - -## Baking - -The "baking" process is run with `make bake`, `make install`, and `make build` targets, -wrapping the `linode-cli bake` command. - -Objects representing each command are serialized into the `data-3` file via the [pickle](https://docs.python.org/3/library/pickle.html) -package, and are included in release artifacts as a [data file](https://setuptools.pypa.io/en/latest/userguide/datafiles.html). -This enables quick command loading at runtime and eliminates the need for runtime parsing logic. - -## Configuration - -The Linode CLI can be configured using the `linode-cli configure` command, which allows users to -configure the following: - -- A Linode API token - - This can optionally be done using OAuth, see [OAuth Authentication](#oauth-authentication) -- Default values for commonly used fields (e.g. region, image) -- Overrides for the target API URL (hostname, version, scheme, etc.) - -This command serves as an interactive prompt and outputs a configuration file to `~/.config/linode-cli`. -This file is in a simple INI format and can be easily modified manually by users. - -Additionally, multiple users can be created for the CLI which can be designated when running commands using the `--as-user` argument -or using the `default-user` config variable. - -When running a command, the config file is loaded into a `CLIConfig` object stored under the `CLI.config` field. -This object allows various parts of the CLI to access the current user, the configured token, and any other CLI config values by name. - -The logic for the interactive prompt and the logic for storing the CLI configuration can be found in the -`configuration` package. - -## OAuth Authentication - -In addition to allowing users to configure a token manually, they can automatically generate a CLI token under their account using -an OAuth workflow. This workflow uses the [Linode OAuth API](https://www.linode.com/docs/api/#oauth) to generate a temporary token, -which is then used to generate a long-term token stored in the CLI config file. - -The OAuth client ID is hardcoded and references a client under an officially managed Linode account. - -The rough steps of this OAuth workflow are as follows: - -1. The CLI checks whether a browser can be opened. If not, manually prompt the user for a token and skip. -2. Open a local HTTP server on an arbitrary port that exposes `oauth-landing-page.html`. This will also extract the token from the callback. -3. Open the user's browser to the OAuth URL with the hardcoded client ID and the callback URL pointing to the local webserver. -4. Once the user authorizes the OAuth application, they will be redirected to the local webserver where the temporary token will be extracted. -5. With the extracted token, a new token is generated with the default callback and a name similar to `Linode CLI @ localhost`. - -All the logic for OAuth token generation is stored in the `configuration/auth.py` file. - -## Outputs - -The Linode CLI uses the [Rich Python package](https://rich.readthedocs.io/en/latest/) to render tables, colorize text, -and handle other complex terminal output operations. - -## Output Overrides - -For special cases where the desired output may not be possible using OpenAPI spec extensions alone, developers -can implement special override functions that are given the output JSON and print a custom output to stdout. - -These overrides are specified using the `@output_override` decorator and can be found in the `overrides.py` file. - -## Command Completions - -The Linode CLI allows users to dynamically generate shell completions for the Bash and Fish shells. -This works by rendering hardcoded templates for each baked/generated command. - -See `completion.py` for more details. - -## Next Steps - -To continue to the next step of this guide, continue to the [Skeleton page](./Development%20-%20Skeleton). diff --git a/wiki/development/Development - Setup.md b/wiki/development/Development - Setup.md deleted file mode 100644 index bf667fcbe..000000000 --- a/wiki/development/Development - Setup.md +++ /dev/null @@ -1,86 +0,0 @@ -The following guide outlines to the process for setting up the Linode CLI for development. - -## Cloning the Repository - -The Linode CLI repository can be cloned locally using the following command: - -```bash -git clone git@github.com:linode/linode-cli.git -``` - -If you do not have an SSH key configured, you can alternatively use the following command: - -```bash -git clone https://github.com/linode/linode-cli.git -``` - -## Configuring a VirtualEnv (recommended) - -A virtual env allows you to create virtual Python environment which can prevent potential -Python dependency conflicts. - -To create a VirtualEnv, run the following: - -```bash -python3 -m venv .venv -``` - -To enter the VirtualEnv, run the following command (NOTE: This needs to be run every time you open your shell): - -```bash -source .venv/bin/activate -``` - -## Installing Project Dependencies - -All Linode CLI Python requirements can be installed by running the following command: - -```bash -make requirements -``` - -## Building and Installing the Project - -The Linode CLI can be built and installed using the `make install` target: - -```bash -make install -``` - -Alternatively you can build but not install the CLI using the `make build` target: - -```bash -make build -``` - -Optionally you can validate that you have installed a local version of the CLI using the `linode-cli --version` command: - -```bash -linode-cli --version - -# Output: -# linode-cli 0.0.0 -# Built from spec version 4.173.0 -# -# The 0.0.0 implies this is a locally built version of the CLI -``` - -## Building Using a Custom OpenAPI Specification - -In some cases, you may want to build the CLI using a custom or modified OpenAPI specification. - -This can be achieved using the `SPEC` Makefile argument, for example: - -```bash -# Download the OpenAPI spec -curl -o openapi.yaml https://raw.githubusercontent.com/linode/linode-api-docs/development/openapi.yaml - -# Many arbitrary changes to the spec - -# Build & install the CLI using the modified spec -make SPEC=$PWD/openapi.yaml install -``` - -## Next Steps - -To continue to the next step of this guide, continue to the [Testing page](./Development%20-%20Testing). \ No newline at end of file diff --git a/wiki/development/Development - Skeleton.md b/wiki/development/Development - Skeleton.md deleted file mode 100644 index ff9633d77..000000000 --- a/wiki/development/Development - Skeleton.md +++ /dev/null @@ -1,32 +0,0 @@ -The following section outlines the purpose of each file in the CLI. - -* `linode-cli` - * `baked` - * `__init__.py` - Contains imports for certain classes in this package - * `colors.py` - Contains logic for colorizing strings in CLI outputs (deprecated) - * `operation.py` - Contains the logic to parse an `OpenAPIOperation` from the OpenAPI spec and generate/execute a corresponding argparse parser - * `request.py` - Contains the `OpenAPIRequest` and `OpenAPIRequestArg` classes - * `response.py` - Contains `OpenAPIResponse` and `OpenAPIResponseAttr` classes - * `configuration` - * `__init__.py` - Contains imports for certain classes in this package - * `auth.py` - Contains all the logic for the token generation OAuth workflow - * `config.py` - Contains all the logic for loading, updating, and saving CLI configs - * `helpers.py` - Contains various config-related helpers - * `plugins` - * `__init__.py` - Contains imports for certain classes in this package - * `plugins.py` - Contains the shared wrapper that allows plugins to access CLI functionality - * `__init__.py` - Contains the main entrypoint for the CLI; routes top-level commands to their corresponding functions - * `__main__.py` - Calls the project entrypoint in `__init__.py` - * `api_request.py` - Contains logic for building API request bodies, making API requests, and handling API responses/errors - * `arg_helpers.py` - Contains miscellaneous logic for registering common argparse arguments and loading the OpenAPI spec - * `cli.py` - Contains the `CLI` class, which routes all the logic baking, loading, executing, and outputting generated CLI commands - * `completion.py` - Contains all the logic for generating shell completion files (`linode-cli completion`) - * `helpers.py` - Contains various miscellaneous helpers, especially relating to string manipulation, etc. - * `oauth-landing-page.html` - The page to show users in their browser when the OAuth workflow is complete. - * `output.py` - Contains all the logic for handling generated command outputs, including formatting tables, filtering JSON, etc. - * `overrides.py` - Contains hardcoded output override functions for select CLI commands. - - -## Next Steps - -To continue to the next step of this guide, continue to the [Setup page](./Development%20-%20Setup). \ No newline at end of file diff --git a/wiki/development/Development - Testing.md b/wiki/development/Development - Testing.md deleted file mode 100644 index c97c04b46..000000000 --- a/wiki/development/Development - Testing.md +++ /dev/null @@ -1,31 +0,0 @@ -This page gives an overview of how to run the various test suites for the Linode CLI. - -Before running any tests, built and installed the Linode CLI with your changes using `make install`. - -## Running Unit Tests - -Unit tests can be run using the `make testunit` Makefile target. - -## Running Integration Tests - -Running the tests locally is simple. The only requirements are that you export Linode API token as `LINODE_CLI_TOKEN`:: -```bash -export LINODE_CLI_TOKEN="your_token" -``` - -More information on Managing Linode API tokens can be found in our [API Token Docs](https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/). - -In order to run the full integration test, run:: -```bash -make testint -``` - -To run specific test package, use environment variable `INTEGRATION_TEST_PATH` with `testint` command:: -```bash -make INTEGRATION_TEST_PATH="cli" testint -``` - -Lastly, to run specific test case, use environment variables `TEST_CASE` with `testint` command:: -```bash -make TEST_CASE=test_help_page_for_non_aliased_actions testint -```