diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index a38691714e..3c8515e625 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -77,6 +77,7 @@ jobs: with: name: docs-site path: ./site/** + include-hidden-files: true # https://github.com/marketplace/actions/alls-green#why docs-all-green: # This job does nothing and is only used for the branch protection diff --git a/.github/workflows/issue-manager.yml b/.github/workflows/issue-manager.yml index f6e912a23b..ff71ba5ec3 100644 --- a/.github/workflows/issue-manager.yml +++ b/.github/workflows/issue-manager.yml @@ -2,7 +2,7 @@ name: Issue Manager on: schedule: - - cron: "10 4 * * *" + - cron: "13 21 * * *" issue_comment: types: - created @@ -16,17 +16,18 @@ on: permissions: issues: write + pull-requests: write jobs: issue-manager: - if: github.repository_owner == 'tiangolo' + if: github.repository_owner == 'fastapi' runs-on: ubuntu-latest steps: - name: Dump GitHub context env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - - uses: tiangolo/issue-manager@0.5.0 + - uses: tiangolo/issue-manager@0.5.1 with: token: ${{ secrets.GITHUB_TOKEN }} config: > @@ -35,8 +36,8 @@ jobs: "delay": 864000, "message": "Assuming the original need was handled, this will be automatically closed now. But feel free to add more comments or create new issues or PRs." }, - "changes-requested": { + "waiting": { "delay": 2628000, - "message": "As this PR had requested changes to be applied but has been inactive for a while, it's now going to be closed. But if there's anyone interested, feel free to create a new PR." + "message": "As this PR has been waiting for the original user for a while but seems to be inactive, it's now going to be closed. But if there's anyone interested, feel free to create a new PR." } } diff --git a/.github/workflows/latest-changes.yml b/.github/workflows/latest-changes.yml index 31c4ea724b..6a3ac059a5 100644 --- a/.github/workflows/latest-changes.yml +++ b/.github/workflows/latest-changes.yml @@ -34,8 +34,7 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} with: limit-access-to-actor: true - - uses: docker://tiangolo/latest-changes:0.3.0 - # - uses: tiangolo/latest-changes@main + - uses: tiangolo/latest-changes@0.3.1 with: token: ${{ secrets.GITHUB_TOKEN }} latest_changes_file: docs/release-notes.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 60e6c43836..a2dd05a810 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,4 +36,4 @@ jobs: TIANGOLO_BUILD_PACKAGE: ${{ matrix.package }} run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@v1.9.0 + uses: pypa/gh-action-pypi-publish@v1.10.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5030d0bfdd..b8837ec1d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,6 +67,7 @@ jobs: with: name: coverage-${{ runner.os }}-${{ matrix.python-version }} path: coverage + include-hidden-files: true coverage-combine: needs: [test] @@ -99,6 +100,7 @@ jobs: with: name: coverage-html path: htmlcov + include-hidden-files: true # https://github.com/marketplace/actions/alls-green#why check: # This job does nothing and is only used for the branch protection diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3289dd0950..f74816f123 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ default_language_version: python: python3.10 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-added-large-files - id: check-toml @@ -14,7 +14,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.0 + rev: v0.6.4 hooks: - id: ruff args: diff --git a/README.md b/README.md index 49b441055f..b1e8c17eb2 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ The key features are: ## Installation +Create and activate a virtual environment and then install **Typer**: +
```console diff --git a/docs/alternatives.md b/docs/alternatives.md index 656a4ef660..1f1b7904de 100644 --- a/docs/alternatives.md +++ b/docs/alternatives.md @@ -22,7 +22,7 @@ Provide a better development experience than just reading *CLI Parameters* by ha /// -### Hug +### Hug Hug is a library to create APIs and CLIs, it uses parameters in functions to declare the required data. diff --git a/docs/contributing.md b/docs/contributing.md index a4a158543a..7600780afe 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -4,247 +4,302 @@ First, you might want to see the basic ways to [help Typer and get help](help-ty ## Developing -If you already cloned the repository and you know that you need to deep dive in the code, here are some guidelines to set up your environment. +If you already cloned the typer repository and you want to deep dive in the code, here are some guidelines to set up your environment. -### Virtual environment with `venv` +### Virtual Environment -You can create a virtual environment in a directory using Python's `venv` module: +Follow the instructions to create and activate a [virtual environment](virtual-environments.md){.internal-link target=_blank} for the internal code of `typer`. + +### Install Requirements Using `pip` + +After activating the environment, install the required packages:
```console -$ python -m venv env +$ pip install -r requirements.txt + +---> 100% ```
-That will create a directory `./env/` with the Python binaries and then you will be able to install packages for that isolated environment. +It will install all the dependencies and your local Typer in your local environment. -### Activate the environment +### Using your Local Typer -Activate the new environment with: +If you create a Python file that imports and uses Typer, and run it with the Python from your local environment, it will use your cloned local Typer source code. -//// tab | Linux, macOS +And if you update that local Typer source code when you run that Python file again, it will use the fresh version of Typer you just edited. -
+That way, you don't have to "install" your local version to be able to test every change. -```console -$ source ./env/bin/activate -``` +/// note | "Technical Details" -
+This only happens when you install using this included `requirements.txt` instead of running `pip install typer` directly. + +That is because inside the `requirements.txt` file, the local version of Typer is marked to be installed in "editable" mode, with the `-e` option. + +/// -//// +### Format -//// tab | Windows PowerShell +There is a script that you can run that will format and clean all your code:
```console -$ .\env\Scripts\Activate.ps1 +$ bash scripts/format.sh ```
-//// +It will also auto-sort all your imports. -//// tab | Windows Bash +## Tests -Or if you use Bash for Windows (e.g. Git Bash): +There is a script that you can run locally to test all the code and generate coverage reports in HTML:
```console -$ source ./env/Scripts/activate +$ bash scripts/test-cov-html.sh ```
-//// +This command generates a directory `./htmlcov/`, if you open the file `./htmlcov/index.html` in your browser, you can explore interactively the regions of code that are covered by the tests, and notice if there is any region missing. -To check it worked, use: +## Completion -//// tab | Linux, macOS, Windows Bash +To try and test the completion for different shells and check that they are working you can use a Docker container. -
+There's a `Dockerfile` and a a Docker Compose file `compose.yaml` at `./scripts/docker/`. -```console -$ which pip +It has installed `bash`, `zsh`, `fish`, and `pwsh` (PowerShell for Linux). -some/directory/typer/env/bin/pip -``` +It also has installed `nano` and `vim`, so that you can check the modified configuration files for the shells (for example `.bashrc`, `.zshrc`, etc). -
+It also has `uv` installed, so you can install the dependencies and the project quickly. -//// +The Docker Compose file mounts the main directory as `/code` inside the container, so you can change things and try them out. -//// tab | Windows PowerShell +Go to the `./scripts/docker/` directory: -
+```console +$ cd scripts/docker/ +``` + +Then run an interactive session with `bash` inside the container: ```console -$ Get-Command pip +$ docker compose run typer bash -some/directory/typer/env/bin/pip +root@79c4b9b70cbe:/code# ``` -
+Then inside the container, you can install `typer` with: -//// +```console +$ uv pip install -r requirements.txt +``` -If it shows the `pip` binary at `env/bin/pip` then it worked. 🎉 +Then, you can start the shell you want to use, the one where you want to try out completion: -/// tip +* `bash` +* `fish` +* `pwsh` +* `zsh` -Every time you install a new package with `pip` under that environment, activate the environment again. +For example: -This makes sure that if you use a terminal program installed by that package (like `flit`), you use the one from your local environment and not any other that could be installed globally. +```console +$ zsh +``` -/// +Then install `typer` completion: -### Flit +```console +$ typer --install-completion +``` -**Typer** uses Flit to build, package and publish the project. +/// info -After activating the environment as described above, install `flit`: +In `pwsh` you will probably get a warning of: -
+```plaintext +Set-ExecutionPolicy: Operation is not supported on this platform. +``` -```console -$ pip install flit +this is because that configuration is only available in Windows (and needed there), not in PowerShell for Linux. ----> 100% +/// + +For completion to take effect, you need to restart the shell. So, exit the current shell: + +```console +$ exit ``` -
+and start a new shell (for the same shell you installed completion in) again. For example: -Now re-activate the environment to make sure you are using the `flit` you just installed (and not a global one). +```console +$ zsh +``` -And now use `flit` to install the development dependencies: +Now you could create a demo file on the same Typer directory in your editor, for example `demo.py`: -//// tab | Linux, macOS +```python +import typer -
+app = typer.Typer() -```console -$ flit install --deps develop --symlink ----> 100% -``` +@app.command() +def hello(): + print("Hello") -
-//// +@app.command() +def goodbye(): + print("Goodbye") -//// tab | Windows -If you are on Windows, use `--pth-file` instead of `--symlink`: +if __name__ == "__main__": + app() +``` -
+Because the directory is mounted as a volume, you will be able to access the file from inside the container. -```console -$ flit install --deps develop --pth-file +So, you can try running it with the `typer` command, that will use the installed shell completion: ----> 100% +```console +$ typer demo.py ``` -
+And you should see the completion working: -//// +```console +run -- Run the provided Typer app. +utils -- Extra utility commands for Typer apps. +``` -It will install all the dependencies and your local Typer in your local environment. +And the same for the commands in your `demo.py` file: -#### Using your local Typer +```console +$ typer demo.py run -If you create a Python file that imports and uses Typer, and run it with the Python from your local environment, it will use your local Typer source code. +hello goodbye +``` -And if you update that local Typer source code, as it is installed with `--symlink` (or `--pth-file` on Windows), when you run that Python file again, it will use the fresh version of Typer you just edited. +You can also check the configuration file using `nano` or `vim`, for example: -That way, you don't have to "install" your local version to be able to test every change. +```bash +nano ~/.zshrc +``` -### Format +It will show some content like: -There is a script that you can run that will format and clean all your code: +```bash +fpath+=~/.zfunc; autoload -Uz compinit; compinit -
-```console -$ bash scripts/format.sh +zstyle ':completion:*' menu select ``` -
+If you exit from the container, you can start a new one, you will probably have to install the packages again and install completion again. -It will also auto-sort all your imports. +Using this process, you can test all the shells, with their completions, being able to start from scratch quickly in a fresh container, and verifying that everything works as expected. + +## Docs -For it to sort them correctly, you need to have Typer installed locally in your environment, with the command in the section above using `--symlink` (or `--pth-file` on Windows). +First, make sure you set up your environment as described above, that will install all the requirements. -### Format imports +### Docs live -There is another script that formats all the imports and makes sure you don't have unused imports: +During local development, there is a script that builds the site and checks for any changes, live-reloading:
```console -$ bash scripts/format-imports.sh +$ python ./scripts/docs.py live + +[INFO] Serving on http://127.0.0.1:8008 +[INFO] Start watching changes +[INFO] Start detecting changes ```
-As it runs one command after the other and modifies and reverts many files, it takes a bit longer to run, so it might be easier to use `scripts/format.sh` frequently and `scripts/format-imports.sh` only before committing. +It will serve the documentation on `http://127.0.0.1:8008`. -## Docs +That way, you can edit the documentation/source files and see the changes live. -The documentation uses MkDocs. +/// tip -All the documentation is in Markdown format in the directory `./docs`. +Alternatively, you can perform the same steps that scripts does manually. -Many of the tutorials have blocks of code. +Go into the docs director at `docs/`: -In most of the cases, these blocks of code are actual complete applications that can be run as is. +```console +$ cd docs/ +``` -In fact, those blocks of code are not written inside the Markdown, they are Python files in the `./docs_src/` directory. +Then run `mkdocs` in that directory: -And those Python files are included/injected in the documentation when generating the site. +```console +$ mkdocs serve --dev-addr 8008 +``` -### Docs for tests +/// -Most of the tests actually run against the example source files in the documentation. +#### Typer CLI (optional) -This helps making sure that: +The instructions here show you how to use the script at `./scripts/docs.py` with the `python` program directly. -* The documentation is up to date. -* The documentation examples can be run as is. -* Most of the features are covered by the documentation, ensured by test coverage. +But you can also use Typer CLI, and you will get autocompletion in your terminal for the commands after installing completion. -During local development, there is a script that builds the site and checks for any changes, live-reloading: +If you install Typer CLI, you can install completion with:
```console -$ bash scripts/docs-live.sh +$ typer --install-completion -[INFO] - Building documentation... -[INFO] - Cleaning site directory -[INFO] - Documentation built in 2.74 seconds -[INFO] - Serving on http://127.0.0.1:8008 +zsh completion installed in /home/user/.bashrc. +Completion will take effect once you restart the terminal. ```
-It will serve the documentation on `http://127.0.0.1:8008`. +### Docs Structure -That way, you can edit the documentation/source files and see the changes live. +The documentation uses MkDocs. -## Tests +And there are extra tools/scripts in place in `./scripts/docs.py`. -There is a script that you can run locally to test all the code and generate coverage reports in HTML: +/// tip -
+You don't need to see the code in `./scripts/docs.py`, you just use it in the command line. -```console -$ bash scripts/test-cov-html.sh -``` +/// -
+All the documentation is in Markdown format in the directory `./docs`. -This command generates a directory `./htmlcov/`, if you open the file `./htmlcov/index.html` in your browser, you can explore interactively the regions of code that are covered by the tests, and notice if there is any region missing. +Many of the tutorials have blocks of code. + +In most of the cases, these blocks of code are actual complete applications that can be run as is. + +In fact, those blocks of code are not written inside the Markdown, they are Python files in the `./docs_src/` directory. + +And those Python files are included/injected in the documentation when generating the site. + +### Docs for Tests + +Most of the tests actually run against the example source files in the documentation. + +This helps to make sure that: + +* The documentation is up-to-date. +* The documentation examples can be run as is. +* Most of the features are covered by the documentation, ensured by test coverage. diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 0000000000..1cc66b928a --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,304 @@ +# Environment Variables + +Before we jump into **Typer** code, let's cover a bit some of the **basics** that we'll need to understand how to work with Python (and programming) in general. Let's check a bit about **environment variables**. + +/// tip + +If you already know what "environment variables" are and how to use them, feel free to skip this. + +/// + +An environment variable (also known as "**env var**") is a variable that lives **outside** of the Python code, in the **operating system**, and could be read by your Python code (or by other programs as well). + +Environment variables could be useful for handling application **settings**, as part of the **installation** of Python, etc. + +## Create and Use Env Vars + +You can **create** and use environment variables in the **shell (terminal)**, without needing Python: + +//// tab | Linux, macOS, Windows Bash + +
+ +```console +// You could create an env var MY_NAME with +$ export MY_NAME="Wade Wilson" + +// Then you could use it with other programs, like +$ echo "Hello $MY_NAME" + +Hello Wade Wilson +``` + +
+ +//// + +//// tab | Windows PowerShell + +
+ +```console +// Create an env var MY_NAME +$ $Env:MY_NAME = "Wade Wilson" + +// Use it with other programs, like +$ echo "Hello $Env:MY_NAME" + +Hello Wade Wilson +``` + +
+ +//// + +## Read env vars in Python + +You could also create environment variables **outside** of Python, in the terminal (or with any other method), and then **read them in Python**. + +For example you could have a file `main.py` with: + +```Python hl_lines="3" +import os + +name = os.getenv("MY_NAME", "World") +print(f"Hello {name} from Python") +``` + +/// tip + +The second argument to `os.getenv()` is the default value to return. + +If not provided, it's `None` by default, here we provide `"World"` as the default value to use. + +/// + +Then you could call that Python program: + +//// tab | Linux, macOS, Windows Bash + +
+ +```console +// Here we don't set the env var yet +$ python main.py + +// As we didn't set the env var, we get the default value + +Hello World from Python + +// But if we create an environment variable first +$ export MY_NAME="Wade Wilson" + +// And then call the program again +$ python main.py + +// Now it can read the environment variable + +Hello Wade Wilson from Python +``` + +
+ +//// + +//// tab | Windows PowerShell + +
+ +```console +// Here we don't set the env var yet +$ python main.py + +// As we didn't set the env var, we get the default value + +Hello World from Python + +// But if we create an environment variable first +$ $Env:MY_NAME = "Wade Wilson" + +// And then call the program again +$ python main.py + +// Now it can read the environment variable + +Hello Wade Wilson from Python +``` + +
+ +//// + +As environment variables can be set outside of the code, but can be read by the code, and don't have to be stored (committed to `git`) with the rest of the files, it's common to use them for configurations or **settings**. + +You can also create an environment variable only for a **specific program invocation**, that is only available to that program, and only for its duration. + +To do that, create it right before the program itself, on the same line: + +
+ +```console +// Create an env var MY_NAME in line for this program call +$ MY_NAME="Wade Wilson" python main.py + +// Now it can read the environment variable + +Hello Wade Wilson from Python + +// The env var no longer exists afterwards +$ python main.py + +Hello World from Python +``` + +
+ +/// tip + +You can read more about it at The Twelve-Factor App: Config. + +/// + +## Types and Validation + +These environment variables can only handle **text strings**, as they are external to Python and have to be compatible with other programs and the rest of the system (and even with different operating systems, as Linux, Windows, macOS). + +That means that **any value** read in Python from an environment variable **will be a `str`**, and any conversion to a different type or any validation has to be done in code. + +You will learn more about using environment variables for your CLI applications later in the section about [CLI Arguments with Environment Variables](./tutorial/arguments/envvar.md){.internal-link target=_blank}. + +## `PATH` Environment Variable + +There is a **special** environment variable called **`PATH`** that is used by the operating systems (Linux, macOS, Windows) to find programs to run. + +The value of the variable `PATH` is a long string that is made of directories separated by a colon `:` on Linux and macOS, and by a semicolon `;` on Windows. + +For example, the `PATH` environment variable could look like this: + +//// tab | Linux, macOS + +```plaintext +/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +``` + +This means that the system should look for programs in the directories: + +* `/usr/local/bin` +* `/usr/bin` +* `/bin` +* `/usr/sbin` +* `/sbin` + +//// + +//// tab | Windows + +```plaintext +C:\Program Files\Python312\Scripts;C:\Program Files\Python312;C:\Windows\System32 +``` + +This means that the system should look for programs in the directories: + +* `C:\Program Files\Python312\Scripts` +* `C:\Program Files\Python312` +* `C:\Windows\System32` + +//// + +When you type a **command** in the terminal, the operating system **looks for** the program in **each of those directories** listed in the `PATH` environment variable. + +For example, when you type `python` in the terminal, the operating system looks for a program called `python` in the **first directory** in that list. + +If it finds it, then it will **use it**. Otherwise it keeps looking in the **other directories**. + +### Installing Python and Updating the `PATH` + +When you install Python, you might be asked if you want to update the `PATH` environment variable. + +//// tab | Linux, macOS + +Let's say you install Python and it ends up in a directory `/opt/custompython/bin`. + +If you say yes to update the `PATH` environment variable, then the installer will add `/opt/custompython/bin` to the `PATH` environment variable. + +It could look like this: + +```plaintext +/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/custompython/bin +``` + +This way, when you type `python` in the terminal, the system will find the Python program in `/opt/custompython/bin` (the last directory) and use that one. + +//// + +//// tab | Windows + +Let's say you install Python and it ends up in a directory `C:\opt\custompython\bin`. + +If you say yes to update the `PATH` environment variable, then the installer will add `C:\opt\custompython\bin` to the `PATH` environment variable. + +```plaintext +C:\Program Files\Python312\Scripts;C:\Program Files\Python312;C:\Windows\System32;C:\opt\custompython\bin +``` + +This way, when you type `python` in the terminal, the system will find the Python program in `C:\opt\custompython\bin` (the last directory) and use that one. + +//// + +This way, when you type `python` in the terminal, the system will find the Python program in `/opt/custompython/bin` (the last directory) and use that one. + +So, if you type: + +
+ +```console +$ python +``` + +
+ +//// tab | Linux, macOS + +The system will **find** the `python` program in `/opt/custompython/bin` and run it. + +It would be roughly equivalent to typing: + +
+ +```console +$ /opt/custompython/bin/python +``` + +
+ +//// + +//// tab | Windows + +The system will **find** the `python` program in `C:\opt\custompython\bin\python` and run it. + +It would be roughly equivalent to typing: + +
+ +```console +$ C:\opt\custompython\bin\python +``` + +
+ +//// + +This information will be useful when learning about [Virtual Environments](virtual-environments.md){.internal-link target=_blank}. + +It will also be useful when you **create your own CLI programs** as, for them to be available for your users, they will need to be somewhere in the `PATH` environment variable. + +## Conclusion + +With this you should have a basic understanding of what **environment variables** are and how to use them in Python. + +You can also read more about them in the Wikipedia for Environment Variable. + +In many cases it's not very obvious how environment variables would be useful and applicable right away. But they keep showing up in many different scenarios when you are developing, so it's good to know about them. + +For example, you will need this information in the next section, about [Virtual Environments](virtual-environments.md). diff --git a/docs/index.md b/docs/index.md index 9996f64800..355380729a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,6 +52,8 @@ The key features are: ## Installation +Create and activate a virtual environment and then install **Typer**: +
```console diff --git a/docs/release-notes.md b/docs/release-notes.md index e16bf5488c..f24cb7c721 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,8 +2,67 @@ ## Latest Changes +### Features + +* ✨ Show help items in order of definition. PR [#944](https://github.com/fastapi/typer/pull/944) by [@svlandeg](https://github.com/svlandeg). + +### Refactors + +* 🔥 Remove unused functionality from `_typing.py` file. PR [#805](https://github.com/fastapi/typer/pull/805) by [@ivantodorovich](https://github.com/ivantodorovich). +* ✏️ Fix typo in function name `_make_rich_text`. PR [#959](https://github.com/fastapi/typer/pull/959) by [@svlandeg](https://github.com/svlandeg). + +### Internal + +* ⬆ [pre-commit.ci] pre-commit autoupdate. PR [#982](https://github.com/fastapi/typer/pull/982) by [@pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci). +* ⬆ Bump tiangolo/issue-manager from 0.5.0 to 0.5.1. PR [#980](https://github.com/fastapi/typer/pull/980) by [@dependabot[bot]](https://github.com/apps/dependabot). +* 👷 Update `issue-manager.yml`. PR [#978](https://github.com/fastapi/typer/pull/978) by [@tiangolo](https://github.com/tiangolo). +* ⬆ Bump ruff from 0.6.3 to 0.6.4. PR [#975](https://github.com/fastapi/typer/pull/975) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump mkdocs-material from 9.5.33 to 9.5.34. PR [#963](https://github.com/fastapi/typer/pull/963) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump pypa/gh-action-pypi-publish from 1.9.0 to 1.10.1. PR [#973](https://github.com/fastapi/typer/pull/973) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ [pre-commit.ci] pre-commit autoupdate. PR [#966](https://github.com/fastapi/typer/pull/966) by [@pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci). +* 💚 Set `include-hidden-files` to `True` when using the `upload-artifact` GH action. PR [#967](https://github.com/fastapi/typer/pull/967) by [@svlandeg](https://github.com/svlandeg). +* ⬆ Bump ruff from 0.6.1 to 0.6.3. PR [#961](https://github.com/fastapi/typer/pull/961) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ [pre-commit.ci] pre-commit autoupdate. PR [#689](https://github.com/fastapi/typer/pull/689) by [@pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci). +* ⬆ Bump ruff from 0.2.0 to 0.6.1. PR [#938](https://github.com/fastapi/typer/pull/938) by [@dependabot[bot]](https://github.com/apps/dependabot). +* 👷 Update `latest-changes` GitHub Action. PR [#955](https://github.com/fastapi/typer/pull/955) by [@tiangolo](https://github.com/tiangolo). + +## 0.12.5 + +### Features + +* 💄 Unify the width of the Rich console for help and errors. PR [#788](https://github.com/fastapi/typer/pull/788) by [@racinmat](https://github.com/racinmat). +* 🚸 Improve assertion error message if a group is not a valid subclass. PR [#425](https://github.com/fastapi/typer/pull/425) by [@chrisburr](https://github.com/chrisburr). + +### Fixes + +* 🐛 Ensure `rich_markup_mode=None` disables Rich formatting. PR [#859](https://github.com/fastapi/typer/pull/859) by [@svlandeg](https://github.com/svlandeg). +* 🐛 Fix sourcing of completion path for Git Bash. PR [#801](https://github.com/fastapi/typer/pull/801) by [@svlandeg](https://github.com/svlandeg). +* 🐛 Fix PowerShell completion with incomplete word. PR [#360](https://github.com/fastapi/typer/pull/360) by [@patricksurry](https://github.com/patricksurry). + +### Refactors + +* 🔥 Remove Python 3.6 specific code paths. PR [#850](https://github.com/fastapi/typer/pull/850) by [@svlandeg](https://github.com/svlandeg). +* 🔥 Clean up redundant code. PR [#858](https://github.com/fastapi/typer/pull/858) by [@svlandeg](https://github.com/svlandeg). + +### Docs + +* ♻️ Use F-strings in Click examples in docs. PR [#891](https://github.com/fastapi/typer/pull/891) by [@svlandeg](https://github.com/svlandeg). +* 📝Add missing `main.py` in tutorial on CLI option names. PR [#868](https://github.com/fastapi/typer/pull/868) by [@fsramalho](https://github.com/fsramalho). +* 📝 Fix broken link. PR [#835](https://github.com/fastapi/typer/pull/835) by [@OhioDschungel6](https://github.com/OhioDschungel6). +* 📝 Update package docs with the latest versions of Typer and Poetry. PR [#781](https://github.com/fastapi/typer/pull/781) by [@kinuax](https://github.com/kinuax). +* 📝 Update the Progress Bar tutorial with correct output. PR [#199](https://github.com/fastapi/typer/pull/199) by [@n1ckdm](https://github.com/n1ckdm). +* 📝 Add docs and scripts to test completion in different shells. PR [#953](https://github.com/fastapi/typer/pull/953) by [@tiangolo](https://github.com/tiangolo). +* ✏️ Fix a typo in `docs/virtual-environments.md`. PR [#952](https://github.com/fastapi/typer/pull/952) by [@tiangolo](https://github.com/tiangolo). +* ✏️ Fix typo in `docs/contributing.md`. PR [#947](https://github.com/fastapi/typer/pull/947) by [@tiangolo](https://github.com/tiangolo). +* 📝 Add docs for virtual environments, environment variables, and update contributing. PR [#946](https://github.com/fastapi/typer/pull/946) by [@tiangolo](https://github.com/tiangolo). + ### Internal +* 🔨 Pre-install dependencies in Docker so that testing in Docker is faster. PR [#954](https://github.com/fastapi/typer/pull/954) by [@tiangolo](https://github.com/tiangolo). +* ✅ Add `needs_bash` test fixture. PR [#888](https://github.com/fastapi/typer/pull/888) by [@svlandeg](https://github.com/svlandeg). +* ⬆ Bump mkdocs-material from 9.5.18 to 9.5.33. PR [#945](https://github.com/fastapi/typer/pull/945) by [@dependabot[bot]](https://github.com/apps/dependabot). +* ⬆ Bump pillow from 10.3.0 to 10.4.0. PR [#939](https://github.com/fastapi/typer/pull/939) by [@dependabot[bot]](https://github.com/apps/dependabot). +* 👷 Fix issue-manager. PR [#948](https://github.com/fastapi/typer/pull/948) by [@tiangolo](https://github.com/tiangolo). * 🙈 Remove extra line in .gitignore. PR [#936](https://github.com/fastapi/typer/pull/936) by [@tiangolo](https://github.com/tiangolo). * ⬆ Update pytest-cov requirement from <5.0.0,>=2.10.0 to >=2.10.0,<6.0.0. PR [#844](https://github.com/fastapi/typer/pull/844) by [@dependabot[bot]](https://github.com/apps/dependabot). * ⬆ Bump pypa/gh-action-pypi-publish from 1.8.11 to 1.9.0. PR [#865](https://github.com/fastapi/typer/pull/865) by [@dependabot[bot]](https://github.com/apps/dependabot). diff --git a/docs/tutorial/arguments/envvar.md b/docs/tutorial/arguments/envvar.md index 970d1ee92a..a53d42685e 100644 --- a/docs/tutorial/arguments/envvar.md +++ b/docs/tutorial/arguments/envvar.md @@ -2,6 +2,12 @@ You can also configure a *CLI argument* to read a value from an environment variable if it is not provided in the command line as a *CLI argument*. +/// tip + +You can learn more about environment variables in the [Environment Variables](../../environment-variables.md){.internal-link target=_blank} page. + +/// + To do that, use the `envvar` parameter for `typer.Argument()`: //// tab | Python 3.7+ diff --git a/docs/tutorial/commands/help.md b/docs/tutorial/commands/help.md index b9501b72ee..5a6668038a 100644 --- a/docs/tutorial/commands/help.md +++ b/docs/tutorial/commands/help.md @@ -208,7 +208,7 @@ Then you can use more formatting in the docstrings and the `help` parameter for /// info -By default, `rich_markup_mode` is `None`, which disables any rich text formatting. +By default, `rich_markup_mode` is `None` if Rich is not installed, and `"rich"` if it is installed. In the latter case, you can set `rich_markup_mode` to `None` to disable rich text formatting. /// diff --git a/docs/tutorial/commands/index.md b/docs/tutorial/commands/index.md index d2693849d7..f531c7cfb3 100644 --- a/docs/tutorial/commands/index.md +++ b/docs/tutorial/commands/index.md @@ -257,6 +257,39 @@ Commands:
+ +## Sorting of the commands + +Note that by design, **Typer** shows the commands in the order they've been declared. + +So, if we take our original example, with `create` and `delete` commands, and reverse the order in the Python file: + +```Python hl_lines="7 12" +{!../docs_src/commands/index/tutorial004.py!} +``` + +Then we will see the `delete` command first in the help output: + +
+ +```console +// Check the help +$ python main.py --help + +Usage: main.py [OPTIONS] COMMAND [ARGS]... + +Options: + --install-completion Install completion for the current shell. + --show-completion Show completion for the current shell, to copy it or customize the installation. + --help Show this message and exit. + +Commands: + delete + create +``` + +
+ ## Click Group If you come from Click, a `typer.Typer` app with subcommands is more or less the equivalent of a Click Group. diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index c8ea8a1cb5..bad806c88a 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -6,7 +6,7 @@ It covers everything you need to know from the **simplest scripts** to **complex You could consider this a **book**, a **course**, the **official** and recommended way to learn **Typer**. 😎 -## Python types +## Python Types If you need a refresher about how to use Python type hints, check the first part of FastAPI's Python types intro. @@ -31,17 +31,15 @@ These type hints are what give you autocomplete in your editor and several other **Typer** is based on these type hints. -## Intro +## About this Tutorial This tutorial shows you how to use **Typer** with all its features, step by step. Each section gradually builds on the previous ones, but it's structured to separate topics, so that you can go directly to any specific one to solve your specific CLI needs. -It is also built to work as a future reference. +It is also built to work as a future reference so you can come back and see exactly what you need. -So you can come back and see exactly what you need. - -## Run the code +## Run the Code All the code blocks can be copied and used directly (they are tested Python files). @@ -59,42 +57,8 @@ $ python main.py It is **HIGHLY encouraged** that you write or copy the code, edit it and run it locally. -Using it in your editor is what really shows you the benefits of **Typer**, seeing how little code you have to write, all the type checks, autocompletion, etc. - -And running the examples is what will really help you understand what is going on. - -You can learn a lot more by running some examples and playing around with them than by reading all the docs here. - ---- - -## Install **Typer** - -The first step is to install **Typer**: - -
- -```console -$ pip install typer ----> 100% -Successfully installed typer click shellingham rich -``` - -
- -By default, `typer` comes with `rich` and `shellingham`. - -/// note +Using it in your editor is what really shows you the benefits of **Typer**, seeing how little code you have to write, all the **inline errors**, **autocompletion**, etc. -If you are an advanced user and want to opt out of these default extra dependencies, you can instead install `typer-slim`. - -```bash -pip install typer -``` - -...includes the same optional dependencies as: - -```bash -pip install "typer-slim[standard]" -``` +And running the examples is what will really help you **understand** what is going on. -/// +You can learn a lot more by **running some examples** and **playing around** with them than by reading all the docs here. diff --git a/docs/tutorial/install.md b/docs/tutorial/install.md new file mode 100644 index 0000000000..09ee0cfc2c --- /dev/null +++ b/docs/tutorial/install.md @@ -0,0 +1,33 @@ +# Install **Typer** + +The first step is to install **Typer**. + +First, make sure you create your [virtual environment](../virtual-environments.md){.internal-link target=_blank}, activate it, and then install it, for example with: + +
+ +```console +$ pip install typer +---> 100% +Successfully installed typer click shellingham rich +``` + +
+ +By default, `typer` comes with `rich` and `shellingham`. + +/// note + +If you are an advanced user and want to opt out of these default extra dependencies, you can instead install `typer-slim`. + +```bash +pip install typer +``` + +...includes the same optional dependencies as: + +```bash +pip install "typer-slim[standard]" +``` + +/// diff --git a/docs/tutorial/options/name.md b/docs/tutorial/options/name.md index 7ece83f8b5..a724a5b563 100644 --- a/docs/tutorial/options/name.md +++ b/docs/tutorial/options/name.md @@ -81,7 +81,7 @@ Options: --help Show this message and exit. // Try it -$ python --name Camila +$ python main.py --name Camila Hello Camila ``` diff --git a/docs/tutorial/package.md b/docs/tutorial/package.md index cb04fd6d2c..617c93cd0b 100644 --- a/docs/tutorial/package.md +++ b/docs/tutorial/package.md @@ -49,41 +49,34 @@ cd ./rick-portal-gun ## Dependencies and environment -Add `typer[all]` to your dependencies: +Add `typer` to your dependencies:
```console -$ poetry add "typer[all]" +$ poetry add typer // It creates a virtual environment for your project Creating virtualenv rick-portal-gun-w31dJa0b-py3.10 in /home/rick/.cache/pypoetry/virtualenvs -Using version ^0.1.0 for typer +Using version ^0.12.0 for typer Updating dependencies Resolving dependencies... (1.2s) -Writing lock file - ---> 100% -Package operations: 15 installs, 0 updates, 0 removals - - - Installing zipp (3.1.0) - - Installing importlib-metadata (1.5.0) - - Installing pyparsing (2.4.6) - - Installing six (1.14.0) - - Installing attrs (19.3.0) - - Installing click (7.1.1) - - Installing colorama (0.4.3) - - Installing more-itertools (8.2.0) - - Installing packaging (20.3) - - Installing pluggy (0.13.1) - - Installing py (1.8.1) - - Installing shellingham (1.3.2) - - Installing wcwidth (0.1.8) - - Installing pytest (5.4.1) - - Installing typer (0.0.11) +Package operations: 8 installs, 0 updates, 0 removals + + - Installing mdurl (0.1.2) + - Installing markdown-it-py (3.0.0) + - Installing pygments (2.17.2) + - Installing click (8.1.7) + - Installing rich (13.7.1) + - Installing shellingham (1.5.4) + - Installing typing-extensions (4.11.0) + - Installing typer (0.12.3) + +Writing lock file // Activate that new virtual environment $ poetry shell @@ -106,8 +99,7 @@ You can see that you have a generated project structure that looks like: ├── rick_portal_gun │   └── __init__.py └── tests - ├── __init__.py - └── test_rick_portal_gun.py + └── __init__.py ``` ## Create your app @@ -183,14 +175,11 @@ rick-portal-gun = "rick_portal_gun.main:app" [tool.poetry.dependencies] python = "^3.10" -typer = {extras = ["all"], version = "^0.1.0"} - -[tool.poetry.dev-dependencies] -pytest = "^5.2" +typer = "^0.12.0" [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" ``` Here's what that line means: @@ -239,7 +228,7 @@ Installing dependencies from lock file No dependencies to install or update - - Installing rick-portal-gun (0.1.0) + - Installing the current project: rick-portal-gun (0.1.0) ```
@@ -258,7 +247,7 @@ $ which rick-portal-gun /home/rick/.cache/pypoetry/virtualenvs/rick-portal-gun-w31dJa0b-py3.10/bin/rick-portal-gun // Try it -$ rick-portal-gun +$ rick-portal-gun --help // You get all the standard help Usage: rick-portal-gun [OPTIONS] COMMAND [ARGS]... @@ -292,7 +281,6 @@ $ poetry build Building rick-portal-gun (0.1.0) - Building sdist - Built rick-portal-gun-0.1.0.tar.gz - - Building wheel - Built rick_portal_gun-0.1.0-py3-none-any.whl ``` @@ -320,7 +308,7 @@ Now you can open another terminal and install that package from the file for you
```console -$ pip install --user /home/rock/code/rick-portal-gun/dist/rick_portal_gun-0.1.0-py3-none-any.whl +$ pip install --user /home/rick/rick-portal-gun/dist/rick_portal_gun-0.1.0-py3-none-any.whl ---> 100% ``` @@ -361,7 +349,7 @@ Having it installed globally (and not in a single environment), you can now inst ```console $ rick-portal-gun --install-completion -zsh completion installed in /home/user/.zshrc. +zsh completion installed in /home/rick/.zshrc. Completion will take effect once you restart the terminal. ``` @@ -439,8 +427,7 @@ The file would live right beside `__init__.py`: │ ├── __main__.py │ └── main.py └── tests - ├── __init__.py - └── test_rick_portal_gun.py + └── __init__.py ``` No other file has to import it, you don't have to reference it in your `pyproject.toml` or anything else, it just works by default, as it is standard Python behavior. @@ -457,7 +444,7 @@ Now, after installing your package, if you call it with `python -m` it will work
```console -$ python -m rick_portal_gun +$ python -m rick_portal_gun --help Usage: __main__.py [OPTIONS] COMMAND [ARGS]... @@ -513,7 +500,7 @@ You can pass all the arguments and keyword arguments you could pass to a Click a
```console -$ python -m rick_portal_gun +$ python -m rick_portal_gun --help Usage: rick-portal-gun [OPTIONS] COMMAND [ARGS]... @@ -603,7 +590,6 @@ $ poetry publish --build Building rick-portal-gun (0.1.0) - Building sdist - Built rick-portal-gun-0.1.0.tar.gz - - Building wheel - Built rick_portal_gun-0.1.0-py3-none-any.whl @@ -630,10 +616,10 @@ $ pip uninstall rick-portal-gun Found existing installation: rick-portal-gun 0.1.0 Uninstalling rick-portal-gun-0.1.0: Would remove: - /home/user/.local/bin/rick-portal-gun - /home/user/.local/lib/python3.10/site-packages/rick_portal_gun-0.1.0.dist-info/* - /home/user/.local/lib/python3.10/site-packages/rick_portal_gun/* -# Proceed (y/n)? $ y + /home/rick/.local/bin/rick-portal-gun + /home/rick/.local/lib/python3.10/site-packages/rick_portal_gun-0.1.0.dist-info/* + /home/rick/.local/lib/python3.10/site-packages/rick_portal_gun/* +# Proceed (Y/n)? $ Y Successfully uninstalled rick-portal-gun-0.1.0 ``` @@ -648,11 +634,16 @@ $ pip install --user rick-portal-gun // Notice that it says "Downloading" 🚀 Collecting rick-portal-gun - Downloading rick_portal_gun-0.1.0-py3-none-any.whl (1.8 kB) -Requirement already satisfied: typer[all]<0.0.12,>=0.0.11 in ./.local/lib/python3.10/site-packages (from rick-portal-gun) (0.0.11) -Requirement already satisfied: click<7.2.0,>=7.1.1 in ./anaconda3/lib/python3.10/site-packages (from typer[all]<0.0.12,>=0.0.11->rick-portal-gun) (7.1.1) -Requirement already satisfied: colorama; extra == "all" in ./anaconda3/lib/python3.10/site-packages (from typer[all]<0.0.12,>=0.0.11->rick-portal-gun) (0.4.3) -Requirement already satisfied: shellingham; extra == "all" in ./anaconda3/lib/python3.10/site-packages (from typer[all]<0.0.12,>=0.0.11->rick-portal-gun) (1.3.1) + Downloading rick_portal_gun-0.1.0-py3-none-any.whl.metadata (435 bytes) +Requirement already satisfied: typer<0.13.0,>=0.12.3 in ./.local/lib/python3.10/site-packages (from rick-portal-gun==0.1.0) (0.12.3) +Requirement already satisfied: typing-extensions>=3.7.4.3 in ./.local/lib/python3.10/site-packages (from typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (4.11.0) +Requirement already satisfied: click>=8.0.0 in ./.local/lib/python3.10/site-packages (from typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (8.1.7) +Requirement already satisfied: shellingham>=1.3.0 in ./.local/lib/python3.10/site-packages (from typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (1.5.4) +Requirement already satisfied: rich>=10.11.0 in ./.local/lib/python3.10/site-packages (from typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (13.7.1) +Requirement already satisfied: pygments<3.0.0,>=2.13.0 in ./.local/lib/python3.10/site-packages (from rich>=10.11.0->typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (2.17.2) +Requirement already satisfied: markdown-it-py>=2.2.0 in ./.local/lib/python3.10/site-packages (from rich>=10.11.0->typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (3.0.0) +Requirement already satisfied: mdurl~=0.1 in ./.local/lib/python3.10/site-packages (from markdown-it-py>=2.2.0->rich>=10.11.0->typer<0.13.0,>=0.12.3->rick-portal-gun==0.1.0) (0.1.2) +Downloading rick_portal_gun-0.1.0-py3-none-any.whl (1.8 kB) Installing collected packages: rick-portal-gun Successfully installed rick-portal-gun-0.1.0 ``` @@ -715,14 +706,11 @@ rick-portal-gun = "rick_portal_gun.main:app" [tool.poetry.dependencies] python = "^3.10" -typer = {extras = ["all"], version = "^0.1.0"} - -[tool.poetry.dev-dependencies] -pytest = "^5.2" +typer = "^0.12.0" [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" ``` And in the file `rick_portal_gun/__init__.py`: @@ -743,7 +731,6 @@ $ poetry publish --build Building rick-portal-gun (0.2.0) - Building sdist - Built rick-portal-gun-0.2.0.tar.gz - - Building wheel - Built rick_portal_gun-0.2.0-py3-none-any.whl diff --git a/docs/tutorial/progressbar.md b/docs/tutorial/progressbar.md index 250ea49382..c56cf50b29 100644 --- a/docs/tutorial/progressbar.md +++ b/docs/tutorial/progressbar.md @@ -246,5 +246,5 @@ Check it:
python main.py -Processed 100 things in batches. +Processed 1000 things in batches.
diff --git a/docs/virtual-environments.md b/docs/virtual-environments.md new file mode 100644 index 0000000000..d4db3a77c3 --- /dev/null +++ b/docs/virtual-environments.md @@ -0,0 +1,844 @@ +# Virtual Environments + +When you work in Python projects you probably should use a **virtual environment** (or a similar mechanism) to isolate the packages you install for each project. + +/// info + +If you already know about virtual environments, how to create them and use them, you might want to skip this section. 🤓 + +/// + +/// tip + +A **virtual environment** is different than an **environment variable**. + +An **environment variable** is a variable in the system that can be used by programs. + +A **virtual environment** is a directory with some files in it. + +/// + +/// info + +This page will teach you how to use **virtual environments** and how they work. + +If you are ready to adopt a **tool that manages everything** for you (including installing Python), try uv. + +/// + +## Create a Project + +First, create a directory for your project. + +What I normally do is that I create a directory named `code` inside my home/user directory. + +And inside of that I create one directory per project. + +
+ +```console +// Go to the home directory +$ cd +// Create a directory for all your code projects +$ mkdir code +// Enter into that code directory +$ cd code +// Create a directory for this project +$ mkdir awesome-project +// Enter into that project directory +$ cd awesome-project +``` + +
+ +## Create a Virtual Environment + +When you start working on a Python project **for the first time**, create a virtual environment **inside your project**. + +/// tip + +You only need to do this **once per project**, not every time you work. + +/// + +//// tab | `venv` + +To create a virtual environment, you can use the `venv` module that comes with Python. + +
+ +```console +$ python -m venv .venv +``` + +
+ +/// details | What that command means + +* `python`: use the program called `python` +* `-m`: call a module as a script, we'll tell it which module next +* `venv`: use the module called `venv` that normally comes installed with Python +* `.venv`: create the virtual environment in the new directory `.venv` + +/// + +//// + +//// tab | `uv` + +If you have `uv` installed, you can use it to create a virtual environment. + +
+ +```console +$ uv venv +``` + +
+ +/// tip + +By default, `uv` will create a virtual environment in a directory called `.venv`. + +But you could customize it passing an additional argument with the directory name. + +/// + +//// + +That command creates a new virtual environment in a directory called `.venv`. + +/// details | `.venv` or other name + +You could create the virtual environment in a different directory, but there's a convention of calling it `.venv`. + +/// + +## Activate the Virtual Environment + +Activate the new virtual environment so that any Python command you run or package you install uses it. + +/// tip + +Do this **every time** you start a **new terminal session** to work on the project. + +/// + +//// tab | Linux, macOS + +
+ +```console +$ source .venv/bin/activate +``` + +
+ +//// + +//// tab | Windows PowerShell + +
+ +```console +$ .venv\Scripts\Activate.ps1 +``` + +
+ +//// + +//// tab | Windows Bash + +Or if you use Bash for Windows (e.g. Git Bash): + +
+ +```console +$ source .venv/Scripts/activate +``` + +
+ +//// + +/// tip + +Every time you install a **new package** in that environment, **activate** the environment again. + +This makes sure that if you use a **terminal (CLI) program** installed by that package, you use the one from your virtual environment and not any other that could be installed globally, probably with a different version than what you need. + +/// + +## Check the Virtual Environment is Active + +Check that the virtual environment is active (the previous command worked). + +/// tip + +This is **optional**, but it's a good way to **check** that everything is working as expected and you are using the virtual environment you intended. + +/// + +//// tab | Linux, macOS, Windows Bash + +
+ +```console +$ which python + +/home/user/code/awesome-project/.venv/bin/python +``` + +
+ +If it shows the `python` binary at `.venv/bin/python`, inside of your project (in this case `awesome-project`), then it worked. 🎉 + +//// + +//// tab | Windows PowerShell + +
+ +```console +$ Get-Command python + +C:\Users\user\code\awesome-project\.venv\Scripts\python +``` + +
+ +If it shows the `python` binary at `.venv\Scripts\python`, inside of your project (in this case `awesome-project`), then it worked. 🎉 + +//// + +## Upgrade `pip` + +/// tip + +If you use `uv` you would use it to install things instead of `pip`, so you don't need to upgrade `pip`. 😎 + +/// + +If you are using `pip` to install packages (it comes by default with Python), you should **upgrade** it to the latest version. + +Many exotic errors while installing a package are solved by just upgrading `pip` first. + +/// tip + +You would normally do this **once**, right after you create the virtual environment. + +/// + +Make sure the virtual environment is active (with the command above) and then run: + +
+ +```console +$ python -m pip install --upgrade pip + +---> 100% +``` + +
+ +## Add `.gitignore` + +If you are using **Git** (you should), add a `.gitignore` file to exclude everything in your `.venv` from Git. + +/// tip + +If you used `uv` to create the virtual environment, it already did this for you, you can skip this step. 😎 + +/// + +/// tip + +Do this **once**, right after you create the virtual environment. + +/// + +
+ +```console +$ echo "*" > .venv/.gitignore +``` + +
+ +/// details | What that command means + +* `echo "*"`: will "print" the text `*` in the terminal (the next part changes that a bit) +* `>`: anything printed to the terminal by the command to the left of `>` should not be printed but instead written to the file that goes to the right of `>` +* `.gitignore`: the name of the file where the text should be written + +And `*` for Git means "everything". So, it will ignore everything in the `.venv` directory. + +That command will create a file `.gitignore` with the content: + +```gitignore +* +``` + +/// + +## Install Packages + +After activating the environment, you can install packages in it. + +/// tip + +Do this **once** when installing or upgrading the packages your project needs. + +If you need to upgrade a version or add a new package you would **do this again**. + +/// + +### Install Packages Directly + +If you're in a hurry and don't want to use a file to declare your project's package requirements, you can install them directly. + +/// tip + +It's a (very) good idea to put the packages and versions your program needs in a file (for example `requirements.txt` or `pyproject.toml`). + +/// + +//// tab | `pip` + +
+ +```console +$ pip install typer + +---> 100% +``` + +
+ +//// + +//// tab | `uv` + +If you have `uv`: + +
+ +```console +$ uv pip install typer +---> 100% +``` + +
+ +//// + +### Install from `requirements.txt` + +If you have a `requirements.txt`, you can now use it to install its packages. + +//// tab | `pip` + +
+ +```console +$ pip install -r requirements.txt +---> 100% +``` + +
+ +//// + +//// tab | `uv` + +If you have `uv`: + +
+ +```console +$ uv pip install -r requirements.txt +---> 100% +``` + +
+ +//// + +/// details | `requirements.txt` + +A `requirements.txt` with some packages could look like: + +```requirements.txt +typer==0.13.0 +rich==13.7.1 +``` + +/// + +## Run Your Program + +After you activated the virtual environment, you can run your program, and it will use the Python inside of your virtual environment with the packages you installed there. + +
+ +```console +$ python main.py + +Hello World +``` + +
+ +## Configure Your Editor + +You would probably use an editor, make sure you configure it to use the same virtual environment you created (it will probably autodetect it) so that you can get autocompletion and inline errors. + +For example: + +* VS Code +* PyCharm + +/// tip + +You normally have to do this only **once**, when you create the virtual environment. + +/// + +## Deactivate the Virtual Environment + +Once you are done working on your project you can **deactivate** the virtual environment. + +
+ +```console +$ deactivate +``` + +
+ +This way, when you run `python` it won't try to run it from that virtual environment with the packages installed there. + +## Ready to Work + +Now you're ready to start working on your project. + + + +/// tip + +Do you want to understand what's all that above? + +Continue reading. 👇🤓 + +/// + +## Why Virtual Environments + +To work with Typer you need to install Python. + +After that, you would need to **install** Typer and any other **packages** you want to use. + +To install packages you would normally use the `pip` command that comes with Python (or similar alternatives). + +Nevertheless, if you just use `pip` directly, the packages would be installed in your **global Python environment** (the global installation of Python). + +### The Problem + +So, what's the problem with installing packages in the global Python environment? + +At some point, you will probably end up writing many different programs that depend on **different packages**. And some of these projects you work on will depend on **different versions** of the same package. 😱 + +For example, you could create a project called `philosophers-stone`, this program depends on another package called **`harry`, using the version `1`**. So, you need to install `harry`. + +```mermaid +flowchart LR + stone(philosophers-stone) -->|requires| harry-1[harry v1] +``` + +Then, at some point later, you create another project called `prisoner-of-azkaban`, and this project also depends on `harry`, but this project needs **`harry` version `3`**. + +```mermaid +flowchart LR + azkaban(prisoner-of-azkaban) --> |requires| harry-3[harry v3] +``` + +But now the problem is, if you install the packages globally (in the global environment) instead of in a local **virtual environment**, you will have to choose which version of `harry` to install. + +If you want to run `philosophers-stone` you will need to first install `harry` version `1`, for example with: + +
+ +```console +$ pip install "harry==1" +``` + +
+ +And then you would end up with `harry` version `1` installed in your global Python environment. + +```mermaid +flowchart LR + subgraph global[global env] + harry-1[harry v1] + end + subgraph stone-project[philosophers-stone project] + stone(philosophers-stone) -->|requires| harry-1 + end +``` + +But then if you want to run `prisoner-of-azkaban`, you will need to uninstall `harry` version `1` and install `harry` version `3` (or just installing version `3` would automatically uninstall version `1`). + +
+ +```console +$ pip install "harry==3" +``` + +
+ +And then you would end up with `harry` version `3` installed in your global Python environment. + +And if you try to run `philosophers-stone` again, there's a chance it would **not work** because it needs `harry` version `1`. + +```mermaid +flowchart LR + subgraph global[global env] + harry-1[harry v1] + style harry-1 fill:#ccc,stroke-dasharray: 5 5 + harry-3[harry v3] + end + subgraph stone-project[philosophers-stone project] + stone(philosophers-stone) -.-x|⛔️| harry-1 + end + subgraph azkaban-project[prisoner-of-azkaban project] + azkaban(prisoner-of-azkaban) --> |requires| harry-3 + end +``` + +/// tip + +It's very common in Python packages to try the best to **avoid breaking changes** in **new versions**, but it's better to be safe, and install newer versions intentionally and when you can run the tests to check everything is working correctly. + +/// + +Now, imagine that with **many** other **packages** that all your **projects depend on**. That's very difficult to manage. And you would probably end up running some projects with some **incompatible versions** of the packages, and not knowing why something isn't working. + +Also, depending on your operating system (e.g. Linux, Windows, macOS), it could have come with Python already installed. And in that case it probably had some packages pre-installed with some specific versions **needed by your system**. If you install packages in the global Python environment, you could end up **breaking** some of the programs that came with your operating system. + +## Where are Packages Installed + +When you install Python, it creates some directories with some files in your computer. + +Some of these directories are the ones in charge of having all the packages you install. + +When you run: + +
+ +```console +// Don't run this now, it's just an example 🤓 +$ pip install typer +---> 100% +``` + +
+ +That will download a compressed file with the Typer code, normally from PyPI. + +It will also **download** files for other packages that Typer depends on. + +Then it will **extract** all those files and put them in a directory in your computer. + +By default, it will put those files downloaded and extracted in the directory that comes with your Python installation, that's the **global environment**. + +## What are Virtual Environments + +The solution to the problems of having all the packages in the global environment is to use a **virtual environment for each project** you work on. + +A virtual environment is a **directory**, very similar to the global one, where you can install the packages for a project. + +This way, each project will have its own virtual environment (`.venv` directory) with its own packages. + +```mermaid +flowchart TB + subgraph stone-project[philosophers-stone project] + stone(philosophers-stone) --->|requires| harry-1 + subgraph venv1[.venv] + harry-1[harry v1] + end + end + subgraph azkaban-project[prisoner-of-azkaban project] + azkaban(prisoner-of-azkaban) --->|requires| harry-3 + subgraph venv2[.venv] + harry-3[harry v3] + end + end + stone-project ~~~ azkaban-project +``` + +## What Does Activating a Virtual Environment Mean + +When you activate a virtual environment, for example with: + +//// tab | Linux, macOS + +
+ +```console +$ source .venv/bin/activate +``` + +
+ +//// + +//// tab | Windows PowerShell + +
+ +```console +$ .venv\Scripts\Activate.ps1 +``` + +
+ +//// + +//// tab | Windows Bash + +Or if you use Bash for Windows (e.g. Git Bash): + +
+ +```console +$ source .venv/Scripts/activate +``` + +
+ +//// + +That command will create or modify some [environment variables](environment-variables.md){.internal-link target=_blank} that will be available for the next commands. + +One of those variables is the `PATH` variable. + +/// tip + +You can learn more about the `PATH` environment variable in the [Environment Variables](environment-variables.md#path-environment-variable){.internal-link target=_blank} section. + +/// + +Activating a virtual environment adds its path `.venv/bin` (on Linux and macOS) or `.venv\Scripts` (on Windows) to the `PATH` environment variable. + +Let's say that before activating the environment, the `PATH` variable looked like this: + +//// tab | Linux, macOS + +```plaintext +/usr/bin:/bin:/usr/sbin:/sbin +``` + +That means that the system would look for programs in: + +* `/usr/bin` +* `/bin` +* `/usr/sbin` +* `/sbin` + +//// + +//// tab | Windows + +```plaintext +C:\Windows\System32 +``` + +That means that the system would look for programs in: + +* `C:\Windows\System32` + +//// + +After activating the virtual environment, the `PATH` variable would look something like this: + +//// tab | Linux, macOS + +```plaintext +/home/user/code/awesome-project/.venv/bin:/usr/bin:/bin:/usr/sbin:/sbin +``` + +That means that the system will now start looking first look for programs in: + +```plaintext +/home/user/code/awesome-project/.venv/bin +``` + +before looking in the other directories. + +So, when you type `python` in the terminal, the system will find the Python program in + +```plaintext +/home/user/code/awesome-project/.venv/bin/python +``` + +and use that one. + +//// + +//// tab | Windows + +```plaintext +C:\Users\user\code\awesome-project\.venv\Scripts;C:\Windows\System32 +``` + +That means that the system will now start looking first look for programs in: + +```plaintext +C:\Users\user\code\awesome-project\.venv\Scripts +``` + +before looking in the other directories. + +So, when you type `python` in the terminal, the system will find the Python program in + +```plaintext +C:\Users\user\code\awesome-project\.venv\Scripts\python +``` + +and use that one. + +//// + +An important detail is that it will put the virtual environment path at the **beginning** of the `PATH` variable. The system will find it **before** finding any other Python available. This way, when you run `python`, it will use the Python **from the virtual environment** instead of any other `python` (for example, a `python` from a global environment). + +Activating a virtual environment also changes a couple of other things, but this is one of the most important things it does. + +## Checking a Virtual Environment + +When you check if a virtual environment is active, for example with: + +//// tab | Linux, macOS, Windows Bash + +
+ +```console +$ which python + +/home/user/code/awesome-project/.venv/bin/python +``` + +
+ +//// + +//// tab | Windows PowerShell + +
+ +```console +$ Get-Command python + +C:\Users\user\code\awesome-project\.venv\Scripts\python +``` + +
+ +//// + +That means that the `python` program that will be used is the one **in the virtual environment**. + +you use `which` in Linux and macOS and `Get-Command` in Windows PowerShell. + +The way that command works is that it will go and check in the `PATH` environment variable, going through **each path in order**, looking for the program called `python`. Once it finds it, it will **show you the path** to that program. + +The most important part is that when you call `python`, that is the exact "`python`" that will be executed. + +So, you can confirm if you are in the correct virtual environment. + +/// tip + +It's easy to activate one virtual environment, get one Python, and then **go to another project**. + +And the second project **wouldn't work** because you are using the **incorrect Python**, from a virtual environment for another project. + +It's useful being able to check what `python` is being used. 🤓 + +/// + +## Why Deactivate a Virtual Environment + +For example, you could be working on a project `philosophers-stone`, **activate that virtual environment**, install packages and work with that environment. + +And then you want to work on **another project** `prisoner-of-azkaban`. + +You go to that project: + +
+ +```console +$ cd ~/code/prisoner-of-azkaban +``` + +
+ +If you don't deactivate the virtual environment for `philosophers-stone`, when you run `python` in the terminal, it will try to use the Python from `philosophers-stone`. + +
+ +```console +$ cd ~/code/prisoner-of-azkaban + +$ python main.py + +// Error importing sirius, it's not installed 😱 +Traceback (most recent call last): + File "main.py", line 1, in + import sirius +``` + +
+ +But if you deactivate the virtual environment and activate the new one for `prisoner-of-askaban` then when you run `python` it will use the Python from the virtual environment in `prisoner-of-azkaban`. + +
+ +```console +$ cd ~/code/prisoner-of-azkaban + +// You don't need to be in the old directory to deactivate, you can do it wherever you are, even after going to the other project 😎 +$ deactivate + +// Activate the virtual environment in prisoner-of-azkaban/.venv 🚀 +$ source .venv/bin/activate + +// Now when you run python, it will find the package sirius installed in this virtual environment ✨ +$ python main.py + +I solemnly swear 🐺 +``` + +
+ +## Alternatives + +This is a simple guide to get you started and teach you how everything works **underneath**. + +There are many **alternatives** to managing virtual environments, package dependencies (requirements), projects. + +Once you are ready and want to use a tool to **manage the entire project**, packages dependencies, virtual environments, etc. I would suggest you try uv. + +`uv` can do a lot of things, it can: + +* **Install Python** for you, including different versions +* Manage the **virtual environment** for your projects +* Install **packages** +* Manage package **dependencies and versions** for your project +* Make sure you have an **exact** set of packages and versions to install, including their dependencies, so that you can be sure that you can run your project in production exactly the same as in your computer while developing, this is called **locking** +* And many other things + +## Conclusion + +If you read and understood all this, now **you know much more** about virtual environments than many developers out there. 🤓 + +Knowing these details will most probably be useful in a future time when you are debugging something that seems complex, but you will know **how it all works underneath**. 😎 diff --git a/docs_src/commands/index/tutorial004.py b/docs_src/commands/index/tutorial004.py new file mode 100644 index 0000000000..83419b695f --- /dev/null +++ b/docs_src/commands/index/tutorial004.py @@ -0,0 +1,17 @@ +import typer + +app = typer.Typer() + + +@app.command() +def delete(): + print("Deleting user: Hiro Hamada") + + +@app.command() +def create(): + print("Creating user: Hiro Hamada") + + +if __name__ == "__main__": + app() diff --git a/docs_src/progressbar/tutorial006.py b/docs_src/progressbar/tutorial006.py index ac94a3ed3e..d83b0da7cb 100644 --- a/docs_src/progressbar/tutorial006.py +++ b/docs_src/progressbar/tutorial006.py @@ -9,6 +9,8 @@ def main(): for batch in range(4): # Fake processing time time.sleep(1) + # Increment by 250 on each loop iteration + # (it will take 4 seconds to reach 1000) progress.update(250) print(f"Processed {total} things in batches.") diff --git a/docs_src/using_click/tutorial001.py b/docs_src/using_click/tutorial001.py index 6fbf00649a..b3260c6494 100644 --- a/docs_src/using_click/tutorial001.py +++ b/docs_src/using_click/tutorial001.py @@ -7,7 +7,7 @@ def hello(count, name): """Simple program that greets NAME for a total of COUNT times.""" for x in range(count): - click.echo("Hello %s!" % name) + click.echo(f"Hello {name}!") if __name__ == "__main__": diff --git a/docs_src/using_click/tutorial003.py b/docs_src/using_click/tutorial003.py index a8f99e4623..5b3967c015 100644 --- a/docs_src/using_click/tutorial003.py +++ b/docs_src/using_click/tutorial003.py @@ -23,7 +23,7 @@ def callback(): @click.option("--name", prompt="Your name", help="The person to greet.") def hello(name): """Simple program that greets NAME for a total of COUNT times.""" - click.echo("Hello %s!" % name) + click.echo(f"Hello {name}!") typer_click_object = typer.main.get_command(app) diff --git a/mkdocs.yml b/mkdocs.yml index cf15b162d9..ead95508a0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,9 @@ nav: - features.md - Tutorial - User Guide: - tutorial/index.md + - environment-variables.md + - virtual-environments.md + - tutorial/install.md - tutorial/first-steps.md - tutorial/printing.md - tutorial/terminating.md diff --git a/pyproject.toml b/pyproject.toml index b64b18d911..ce9d61afa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -194,9 +194,6 @@ ignore = [ # Loop control variable `x` not used within loop body "docs_src/using_click/tutorial001.py" = ["B007"] -# TODO: refactor _typing.py, remove unnecessary code -"typer/_typing.py" = ["UP036", "F822"] - [tool.ruff.lint.isort] known-third-party = ["typer", "click"] # For docs_src/subcommands/tutorial003/ diff --git a/requirements-docs.txt b/requirements-docs.txt index 4ad8149417..ddee32cb92 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,13 +1,13 @@ -e . -mkdocs-material==9.5.18 +mkdocs-material==9.5.34 mdx-include >=1.4.1,<2.0.0 mkdocs-redirects>=1.2.1,<1.3.0 pyyaml >=5.3.1,<7.0.0 # For Material for MkDocs, Chinese search # jieba==0.42.1 # For image processing by Material for MkDocs -pillow==10.3.0 +pillow==10.4.0 # For image processing by Material for MkDocs cairosvg==2.7.1 # mkdocstrings[python]==0.25.1 diff --git a/requirements-tests.txt b/requirements-tests.txt index ac6377c10d..df1aeb1f61 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -6,7 +6,7 @@ coverage[toml] >=6.2,<8.0 pytest-xdist >=1.32.0,<4.0.0 pytest-sugar >=0.9.4,<1.1.0 mypy ==1.4.1 -ruff ==0.2.0 +ruff ==0.6.4 # Needed explicitly by typer-slim rich >=10.11.0 shellingham >=1.3.0 diff --git a/scripts/docker/Dockerfile b/scripts/docker/Dockerfile new file mode 100644 index 0000000000..b158e2db04 --- /dev/null +++ b/scripts/docker/Dockerfile @@ -0,0 +1,28 @@ +FROM python:latest + +# Add Fish +RUN echo 'deb http://download.opensuse.org/repositories/shells:/fish:/release:/3/Debian_12/ /' | tee /etc/apt/sources.list.d/shells:fish:release:3.list +RUN curl -fsSL https://download.opensuse.org/repositories/shells:fish:release:3/Debian_12/Release.key | gpg --dearmor | tee /etc/apt/trusted.gpg.d/shells_fish_release_3.gpg > /dev/null + +# Install packages including Fish, Zsh, PowerShell +RUN apt-get update && apt-get install -y \ + wget \ + apt-transport-https \ + software-properties-common \ + nano \ + vim \ + fish \ + zsh \ + && wget https://github.com/PowerShell/PowerShell/releases/download/v7.4.4/powershell_7.4.4-1.deb_amd64.deb \ + && dpkg -i powershell_7.4.4-1.deb_amd64.deb + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +ENV UV_SYSTEM_PYTHON=1 + +COPY . /code + +WORKDIR /code + +RUN uv pip install -r requirements.txt diff --git a/scripts/docker/compose.yaml b/scripts/docker/compose.yaml new file mode 100644 index 0000000000..efe17f9efd --- /dev/null +++ b/scripts/docker/compose.yaml @@ -0,0 +1,8 @@ +services: + typer: + build: + context: ../../ + dockerfile: scripts/docker/Dockerfile + volumes: + - ../../:/code + command: sleep infinity diff --git a/tests/assets/cli/multiapp-docs-title.md b/tests/assets/cli/multiapp-docs-title.md index e688a9ba91..ffde843736 100644 --- a/tests/assets/cli/multiapp-docs-title.md +++ b/tests/assets/cli/multiapp-docs-title.md @@ -18,41 +18,41 @@ The end **Commands**: -* `sub` * `top`: Top command +* `sub` -## `multiapp sub` +## `multiapp top` + +Top command **Usage**: ```console -$ multiapp sub [OPTIONS] COMMAND [ARGS]... +$ multiapp top [OPTIONS] ``` **Options**: * `--help`: Show this message and exit. -**Commands**: - -* `bye`: Say bye -* `hello`: Say Hello -* `hi`: Say Hi - -### `multiapp sub bye` - -Say bye +## `multiapp sub` **Usage**: ```console -$ multiapp sub bye [OPTIONS] +$ multiapp sub [OPTIONS] COMMAND [ARGS]... ``` **Options**: * `--help`: Show this message and exit. +**Commands**: + +* `hello`: Say Hello +* `hi`: Say Hi +* `bye`: Say bye + ### `multiapp sub hello` Say Hello @@ -87,14 +87,14 @@ $ multiapp sub hi [OPTIONS] [USER] * `--help`: Show this message and exit. -## `multiapp top` +### `multiapp sub bye` -Top command +Say bye **Usage**: ```console -$ multiapp top [OPTIONS] +$ multiapp sub bye [OPTIONS] ``` **Options**: diff --git a/tests/assets/cli/multiapp-docs.md b/tests/assets/cli/multiapp-docs.md index ed4592f5c8..67d02568db 100644 --- a/tests/assets/cli/multiapp-docs.md +++ b/tests/assets/cli/multiapp-docs.md @@ -18,41 +18,41 @@ The end **Commands**: -* `sub` * `top`: Top command +* `sub` -## `multiapp sub` +## `multiapp top` + +Top command **Usage**: ```console -$ multiapp sub [OPTIONS] COMMAND [ARGS]... +$ multiapp top [OPTIONS] ``` **Options**: * `--help`: Show this message and exit. -**Commands**: - -* `bye`: Say bye -* `hello`: Say Hello -* `hi`: Say Hi - -### `multiapp sub bye` - -Say bye +## `multiapp sub` **Usage**: ```console -$ multiapp sub bye [OPTIONS] +$ multiapp sub [OPTIONS] COMMAND [ARGS]... ``` **Options**: * `--help`: Show this message and exit. +**Commands**: + +* `hello`: Say Hello +* `hi`: Say Hi +* `bye`: Say bye + ### `multiapp sub hello` Say Hello @@ -87,14 +87,14 @@ $ multiapp sub hi [OPTIONS] [USER] * `--help`: Show this message and exit. -## `multiapp top` +### `multiapp sub bye` -Top command +Say bye **Usage**: ```console -$ multiapp top [OPTIONS] +$ multiapp sub bye [OPTIONS] ``` **Options**: diff --git a/tests/test_ambiguous_params.py b/tests/test_ambiguous_params.py index 4dbf09b569..0693c8e9aa 100644 --- a/tests/test_ambiguous_params.py +++ b/tests/test_ambiguous_params.py @@ -29,8 +29,7 @@ def test_forbid_default_value_in_annotated_argument(): # This test case only works with `typer.Argument`. `typer.Option` uses positionals # for param_decls too. @app.command() - def cmd(my_param: Annotated[str, typer.Argument("foo")]): - ... # pragma: no cover + def cmd(my_param: Annotated[str, typer.Argument("foo")]): ... # pragma: no cover with pytest.raises(AnnotatedParamWithDefaultValueError) as excinfo: runner.invoke(app) @@ -64,8 +63,7 @@ def test_forbid_annotated_param_and_default_param(param, param_info_type): app = typer.Typer() @app.command() - def cmd(my_param: Annotated[str, param()] = param("foo")): - ... # pragma: no cover + def cmd(my_param: Annotated[str, param()] = param("foo")): ... # pragma: no cover with pytest.raises(MixedAnnotatedAndDefaultStyleError) as excinfo: runner.invoke(app) @@ -83,8 +81,7 @@ def test_forbid_multiple_typer_params_in_annotated(): @app.command() def cmd( my_param: Annotated[str, typer.Argument(), typer.Argument()], - ): - ... # pragma: no cover + ): ... # pragma: no cover with pytest.raises(MultipleTyperAnnotationsError) as excinfo: runner.invoke(app) @@ -121,8 +118,7 @@ def make_string(): @app.command() def cmd( my_param: Annotated[str, param(default_factory=make_string)] = "hello", - ): - ... # pragma: no cover + ): ... # pragma: no cover with pytest.raises(DefaultFactoryAndDefaultValueError) as excinfo: runner.invoke(app) @@ -171,8 +167,7 @@ def make_string(): @app.command() def cmd( my_param: str = param("hi", default_factory=make_string), - ): - ... # pragma: no cover + ): ... # pragma: no cover with pytest.raises(DefaultFactoryAndDefaultValueError) as excinfo: runner.invoke(app) diff --git a/tests/test_completion/test_completion.py b/tests/test_completion/test_completion.py index 703373b226..36581aba09 100644 --- a/tests/test_completion/test_completion.py +++ b/tests/test_completion/test_completion.py @@ -5,9 +5,10 @@ from docs_src.commands.index import tutorial001 as mod -from ..utils import needs_linux +from ..utils import needs_bash, needs_linux +@needs_bash @needs_linux def test_show_completion(): result = subprocess.run( @@ -23,6 +24,7 @@ def test_show_completion(): assert "_TUTORIAL001.PY_COMPLETE=complete_bash" in result.stdout +@needs_bash @needs_linux def test_install_completion(): bash_completion_path: Path = Path.home() / ".bashrc" diff --git a/tests/test_completion/test_completion_install.py b/tests/test_completion/test_completion_install.py index d1a4695bc8..7c9054e250 100644 --- a/tests/test_completion/test_completion_install.py +++ b/tests/test_completion/test_completion_install.py @@ -133,11 +133,6 @@ def test_completion_install_fish(): assert "Completion will take effect once you restart the terminal" in result.stdout -runner = CliRunner() -app = typer.Typer() -app.command()(mod.main) - - def test_completion_install_powershell(): completion_path: Path = ( Path.home() / ".config/powershell/Microsoft.PowerShell_profile.ps1" diff --git a/tests/test_rich_markup_mode.py b/tests/test_rich_markup_mode.py new file mode 100644 index 0000000000..d9fe5bae4b --- /dev/null +++ b/tests/test_rich_markup_mode.py @@ -0,0 +1,40 @@ +import typer +import typer.completion +from typer.testing import CliRunner + +runner = CliRunner() +rounded = ["╭", "─", "┬", "╮", "│", "├", "┼", "┤", "╰", "┴", "╯"] + + +def test_rich_markup_mode_none(): + app = typer.Typer(rich_markup_mode=None) + + @app.command() + def main(arg: str): + """Main function""" + print(f"Hello {arg}") + + assert app.rich_markup_mode is None + + result = runner.invoke(app, ["World"]) + assert "Hello World" in result.stdout + + result = runner.invoke(app, ["--help"]) + assert all(c not in result.stdout for c in rounded) + + +def test_rich_markup_mode_rich(): + app = typer.Typer(rich_markup_mode="rich") + + @app.command() + def main(arg: str): + """Main function""" + print(f"Hello {arg}") + + assert app.rich_markup_mode == "rich" + + result = runner.invoke(app, ["World"]) + assert "Hello World" in result.stdout + + result = runner.invoke(app, ["--help"]) + assert any(c in result.stdout for c in rounded) diff --git a/tests/test_tutorial/test_commands/test_index/test_tutorial004.py b/tests/test_tutorial/test_commands/test_index/test_tutorial004.py new file mode 100644 index 0000000000..ae0139e93a --- /dev/null +++ b/tests/test_tutorial/test_commands/test_index/test_tutorial004.py @@ -0,0 +1,45 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.commands.index import tutorial004 as mod + +app = mod.app + +runner = CliRunner() + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "[OPTIONS] COMMAND [ARGS]..." in result.output + print(result.output) + assert "Commands" in result.output + assert "create" in result.output + assert "delete" in result.output + # Test that the 'delete' command precedes the 'create' command in the help output + create_char = result.output.index("create") + delete_char = result.output.index("delete") + assert delete_char < create_char + + +def test_create(): + result = runner.invoke(app, ["create"]) + assert result.exit_code == 0 + assert "Creating user: Hiro Hamada" in result.output + + +def test_delete(): + result = runner.invoke(app, ["delete"]) + assert result.exit_code == 0 + assert "Deleting user: Hiro Hamada" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_options_autocompletion/test_tutorial003.py b/tests/test_tutorial/test_options_autocompletion/test_tutorial003.py index ebd1e066ba..60304e9e55 100644 --- a/tests/test_tutorial/test_options_autocompletion/test_tutorial003.py +++ b/tests/test_tutorial/test_options_autocompletion/test_tutorial003.py @@ -9,7 +9,7 @@ runner = CliRunner() -def test_completion(): +def test_completion_zsh(): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, @@ -25,6 +25,23 @@ def test_completion(): assert "Sebastian" in result.stdout +def test_completion_powershell(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL003.PY_COMPLETE": "complete_powershell", + "_TYPER_COMPLETE_ARGS": "tutorial003.py --name Seb", + "_TYPER_COMPLETE_WORD_TO_COMPLETE": "Seb", + }, + ) + assert "Camila" not in result.stdout + assert "Carlos" not in result.stdout + assert "Sebastian" in result.stdout + + def test_1(): result = runner.invoke(mod.app, ["--name", "Camila"]) assert result.exit_code == 0 diff --git a/tests/test_tutorial/test_options_autocompletion/test_tutorial003_an.py b/tests/test_tutorial/test_options_autocompletion/test_tutorial003_an.py index 8f12583e80..7688f108f5 100644 --- a/tests/test_tutorial/test_options_autocompletion/test_tutorial003_an.py +++ b/tests/test_tutorial/test_options_autocompletion/test_tutorial003_an.py @@ -9,7 +9,7 @@ runner = CliRunner() -def test_completion(): +def test_completion_zsh(): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], capture_output=True, @@ -25,6 +25,23 @@ def test_completion(): assert "Sebastian" in result.stdout +def test_completion_powershell(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL003_AN.PY_COMPLETE": "complete_powershell", + "_TYPER_COMPLETE_ARGS": "tutorial003.py --name Seb", + "_TYPER_COMPLETE_WORD_TO_COMPLETE": "Seb", + }, + ) + assert "Camila" not in result.stdout + assert "Carlos" not in result.stdout + assert "Sebastian" in result.stdout + + def test_1(): result = runner.invoke(mod.app, ["--name", "Camila"]) assert result.exit_code == 0 diff --git a/tests/utils.py b/tests/utils.py index 9b503ff799..8f51332ee2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,6 +2,18 @@ import pytest +try: + import shellingham + from shellingham import ShellDetectionFailure + + shell = shellingham.detect_shell()[0] +except ImportError: # pragma: no cover + shellingham = None + shell = None +except ShellDetectionFailure: # pragma: no cover + shell = None + + needs_py310 = pytest.mark.skipif( sys.version_info < (3, 10), reason="requires python3.10+" ) @@ -9,3 +21,7 @@ needs_linux = pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Test requires Linux" ) + +needs_bash = pytest.mark.skipif( + not shellingham or not shell or "bash" not in shell, reason="Test requires Bash" +) diff --git a/typer/__init__.py b/typer/__init__.py index dd2531263d..b422dd00d3 100644 --- a/typer/__init__.py +++ b/typer/__init__.py @@ -1,6 +1,6 @@ """Typer, build great CLIs. Easy to code. Based on Python type hints.""" -__version__ = "0.12.4" +__version__ = "0.12.5" from shutil import get_terminal_size as get_terminal_size diff --git a/typer/_completion_classes.py b/typer/_completion_classes.py index 6bb25c13bd..71ba031860 100644 --- a/typer/_completion_classes.py +++ b/typer/_completion_classes.py @@ -175,7 +175,7 @@ def get_completion_args(self) -> Tuple[List[str], str]: completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") incomplete = os.getenv("_TYPER_COMPLETE_WORD_TO_COMPLETE", "") cwords = click.parser.split_arg_string(completion_args) - args = cwords[1:] + args = cwords[1:-1] if incomplete else cwords[1:] return args, incomplete def format_completion(self, item: click.shell_completion.CompletionItem) -> str: diff --git a/typer/_completion_shared.py b/typer/_completion_shared.py index a5ff7b196a..4f40c143c0 100644 --- a/typer/_completion_shared.py +++ b/typer/_completion_shared.py @@ -100,13 +100,13 @@ def install_bash(*, prog_name: str, complete_var: str, shell: str) -> Path: # It seems bash-completion is the official completion system for bash: # Ref: https://www.gnu.org/software/bash/manual/html_node/A-Programmable-Completion-Example.html # But installing in the locations from the docs doesn't seem to have effect - completion_path = Path.home() / f".bash_completions/{prog_name}.sh" + completion_path = Path.home() / ".bash_completions" / f"{prog_name}.sh" rc_path = Path.home() / ".bashrc" rc_path.parent.mkdir(parents=True, exist_ok=True) rc_content = "" if rc_path.is_file(): rc_content = rc_path.read_text() - completion_init_lines = [f"source {completion_path}"] + completion_init_lines = [f"source '{completion_path}'"] for line in completion_init_lines: if line not in rc_content: # pragma: no cover rc_content += f"\n{line}" diff --git a/typer/_typing.py b/typer/_typing.py index b1d2483da0..147a50540b 100644 --- a/typer/_typing.py +++ b/typer/_typing.py @@ -1,347 +1,39 @@ # Copied from pydantic 1.9.2 (the latest version to support python 3.6.) # https://github.com/pydantic/pydantic/blob/v1.9.2/pydantic/typing.py +# Reduced drastically to only include Typer-specific 3.7+ functionality # mypy: ignore-errors import sys -from os import PathLike from typing import ( - TYPE_CHECKING, - AbstractSet, Any, - ClassVar, - Dict, - Generator, - List, - Mapping, - NewType, + Callable, Optional, - Sequence, - Set, Tuple, Type, Union, - _eval_type, - cast, - get_type_hints, ) -from typing_extensions import Annotated, Literal - -try: - from typing import _TypingBase as typing_base # type: ignore -except ImportError: - from typing import _Final as typing_base # type: ignore - -try: - from typing import GenericAlias as TypingGenericAlias # type: ignore -except ImportError: - # python < 3.9 does not have GenericAlias (list[int], tuple[str, ...] and so on) - TypingGenericAlias = () - -try: - from types import UnionType as TypesUnionType # type: ignore -except ImportError: - # python < 3.10 does not have UnionType (str | int, byte | bool and so on) - TypesUnionType = () - - -if sys.version_info < (3, 7): - if TYPE_CHECKING: - - class ForwardRef: - def __init__(self, arg: Any): - pass - - def _eval_type(self, globalns: Any, localns: Any) -> Any: - pass - - else: - from typing import _ForwardRef as ForwardRef -else: - from typing import ForwardRef - - -if sys.version_info < (3, 7): - - def evaluate_forwardref(type_: ForwardRef, globalns: Any, localns: Any) -> Any: - return type_._eval_type(globalns, localns) - -elif sys.version_info < (3, 9): - - def evaluate_forwardref(type_: ForwardRef, globalns: Any, localns: Any) -> Any: - return type_._evaluate(globalns, localns) - -else: - - def evaluate_forwardref(type_: ForwardRef, globalns: Any, localns: Any) -> Any: - # Even though it is the right signature for python 3.9, mypy complains with - # `error: Too many arguments for "_evaluate" of "ForwardRef"` hence the cast... - return cast(Any, type_)._evaluate(globalns, localns, set()) - - -if sys.version_info < (3, 9): - # Ensure we always get all the whole `Annotated` hint, not just the annotated type. - # For 3.6 to 3.8, `get_type_hints` doesn't recognize `typing_extensions.Annotated`, - # so it already returns the full annotation - get_all_type_hints = get_type_hints - -else: - - def get_all_type_hints(obj: Any, globalns: Any = None, localns: Any = None) -> Any: - return get_type_hints(obj, globalns, localns, include_extras=True) - - -if sys.version_info < (3, 7): - from typing import Callable as Callable - - AnyCallable = Callable[..., Any] - NoArgAnyCallable = Callable[[], Any] -else: - from collections.abc import Callable as Callable - from typing import Callable as TypingCallable - - AnyCallable = TypingCallable[..., Any] - NoArgAnyCallable = TypingCallable[[], Any] - - -# Annotated[...] is implemented by returning an instance of one of these classes, depending on -# python/typing_extensions version. -AnnotatedTypeNames = {"AnnotatedMeta", "_AnnotatedAlias"} - - -if sys.version_info < (3, 8): - - def get_origin(t: Type[Any]) -> Optional[Type[Any]]: - if type(t).__name__ in AnnotatedTypeNames: - return cast( - Type[Any], Annotated - ) # mypy complains about _SpecialForm in py3.6 - return getattr(t, "__origin__", None) - -else: - from typing import get_origin as _typing_get_origin - - def get_origin(tp: Type[Any]) -> Optional[Type[Any]]: - """ - We can't directly use `typing.get_origin` since we need a fallback to support - custom generic classes like `ConstrainedList` - It should be useless once https://github.com/cython/cython/issues/3537 is - solved and https://github.com/samuelcolvin/pydantic/pull/1753 is merged. - """ - if type(tp).__name__ in AnnotatedTypeNames: - return cast(Type[Any], Annotated) # mypy complains about _SpecialForm - return _typing_get_origin(tp) or getattr(tp, "__origin__", None) - - -if sys.version_info < (3, 7): # noqa: C901 (ignore complexity) - - def get_args(t: Type[Any]) -> Tuple[Any, ...]: - """Simplest get_args compatibility layer possible. - - The Python 3.6 typing module does not have `_GenericAlias` so - this won't work for everything. In particular this will not - support the `generics` module (we don't support generic models in - python 3.6). - - """ - if type(t).__name__ in AnnotatedTypeNames: - return t.__args__ + t.__metadata__ - return getattr(t, "__args__", ()) - -elif sys.version_info < (3, 8): # noqa: C901 - from typing import _GenericAlias - - def get_args(t: Type[Any]) -> Tuple[Any, ...]: - """Compatibility version of get_args for python 3.7. - - Mostly compatible with the python 3.8 `typing` module version - and able to handle almost all use cases. - """ - if type(t).__name__ in AnnotatedTypeNames: - return t.__args__ + t.__metadata__ - if isinstance(t, _GenericAlias): - res = t.__args__ - if t.__origin__ is Callable and res and res[0] is not Ellipsis: - res = (list(res[:-1]), res[-1]) - return res - return getattr(t, "__args__", ()) - -else: - from typing import get_args as _typing_get_args - - def _generic_get_args(tp: Type[Any]) -> Tuple[Any, ...]: - """ - In python 3.9, `typing.Dict`, `typing.List`, ... - do have an empty `__args__` by default (instead of the generic ~T for example). - In order to still support `Dict` for example and consider it as `Dict[Any, Any]`, - we retrieve the `_nparams` value that tells us how many parameters it needs. - """ - if hasattr(tp, "_nparams"): - return (Any,) * tp._nparams - return () - - def get_args(tp: Type[Any]) -> Tuple[Any, ...]: - """Get type arguments with all substitutions performed. - - For unions, basic simplifications used by Union constructor are performed. - Examples:: - get_args(Dict[str, int]) == (str, int) - get_args(int) == () - get_args(Union[int, Union[T, int], str][int]) == (int, str) - get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int]) - get_args(Callable[[], T][int]) == ([], int) - """ - if type(tp).__name__ in AnnotatedTypeNames: - return tp.__args__ + tp.__metadata__ - # the fallback is needed for the same reasons as `get_origin` (see above) - return ( - _typing_get_args(tp) or getattr(tp, "__args__", ()) or _generic_get_args(tp) - ) - - -if sys.version_info < (3, 9): - - def convert_generics(tp: Type[Any]) -> Type[Any]: - """Python 3.9 and older only supports generics from `typing` module. - They convert strings to ForwardRef automatically. - - Examples:: - typing.List['Hero'] == typing.List[ForwardRef('Hero')] - """ - return tp - -else: - from typing import _UnionGenericAlias # type: ignore - - from typing_extensions import _AnnotatedAlias - - def convert_generics(tp: Type[Any]) -> Type[Any]: - """ - Recursively searches for `str` type hints and replaces them with ForwardRef. - - Examples:: - convert_generics(list['Hero']) == list[ForwardRef('Hero')] - convert_generics(dict['Hero', 'Team']) == dict[ForwardRef('Hero'), ForwardRef('Team')] - convert_generics(typing.Dict['Hero', 'Team']) == typing.Dict[ForwardRef('Hero'), ForwardRef('Team')] - convert_generics(list[str | 'Hero'] | int) == list[str | ForwardRef('Hero')] | int - """ - origin = get_origin(tp) - if not origin or not hasattr(tp, "__args__"): - return tp - - args = get_args(tp) - - # typing.Annotated needs special treatment - if origin is Annotated: - return _AnnotatedAlias(convert_generics(args[0]), args[1:]) - - # recursively replace `str` instances inside of `GenericAlias` with `ForwardRef(arg)` - converted = tuple( - ForwardRef(arg) - if isinstance(arg, str) and isinstance(tp, TypingGenericAlias) - else convert_generics(arg) - for arg in args - ) - - if converted == args: - return tp - elif isinstance(tp, TypingGenericAlias): - return TypingGenericAlias(origin, converted) - elif isinstance(tp, TypesUnionType): - # recreate types.UnionType (PEP604, Python >= 3.10) - return _UnionGenericAlias(origin, converted) - else: - try: - setattr(tp, "__args__", converted) # noqa: B010 - except AttributeError: - pass - return tp - +from typing_extensions import Literal, get_args, get_origin if sys.version_info < (3, 10): def is_union(tp: Optional[Type[Any]]) -> bool: return tp is Union - WithArgsTypes = (TypingGenericAlias,) - else: import types - import typing def is_union(tp: Optional[Type[Any]]) -> bool: return tp is Union or tp is types.UnionType # noqa: E721 - WithArgsTypes = (typing._GenericAlias, types.GenericAlias, types.UnionType) - - -if sys.version_info < (3, 9): - StrPath = Union[str, PathLike] -else: - StrPath = Union[str, PathLike] - # TODO: Once we switch to Cython 3 to handle generics properly - # (https://github.com/cython/cython/issues/2753), use following lines instead - # of the one above - # # os.PathLike only becomes subscriptable from Python 3.9 onwards - # StrPath = Union[str, PathLike[str]] - - -if TYPE_CHECKING: - # Only in Pydantic - # from .fields import ModelField - - TupleGenerator = Generator[Tuple[str, Any], None, None] - DictStrAny = Dict[str, Any] - DictAny = Dict[Any, Any] - SetStr = Set[str] - ListStr = List[str] - IntStr = Union[int, str] - AbstractSetIntStr = AbstractSet[IntStr] - DictIntStrAny = Dict[IntStr, Any] - MappingIntStrAny = Mapping[IntStr, Any] - CallableGenerator = Generator[AnyCallable, None, None] - ReprArgs = Sequence[Tuple[Optional[str], Any]] - AnyClassMethod = classmethod[Any] __all__ = ( - "ForwardRef", - "Callable", - "AnyCallable", - "NoArgAnyCallable", "NoneType", "is_none_type", - "display_as_type", - "resolve_annotations", "is_callable_type", "is_literal_type", "all_literal_values", - "is_namedtuple", - "is_typeddict", - "is_new_type", - "new_type_supertype", - "is_classvar", - "update_field_forward_refs", - "update_model_forward_refs", - "TupleGenerator", - "DictStrAny", - "DictAny", - "SetStr", - "ListStr", - "IntStr", - "AbstractSetIntStr", - "DictIntStrAny", - "CallableGenerator", - "ReprArgs", - "AnyClassMethod", - "CallableGenerator", - "WithArgsTypes", - "get_args", - "get_origin", - "get_sub_types", - "typing_base", - "get_all_type_hints", "is_union", - "StrPath", ) @@ -352,8 +44,8 @@ def is_union(tp: Optional[Type[Any]]) -> bool: if sys.version_info < (3, 8): - # Even though this implementation is slower, we need it for python 3.6/3.7: - # In python 3.6/3.7 "Literal" is not a builtin type and uses a different + # Even though this implementation is slower, we need it for python 3.7: + # In python 3.7 "Literal" is not a builtin type and uses a different # mechanism. # for this reason `Literal[None] is Literal[None]` evaluates to `False`, # breaking the faster implementation used for the other python versions. @@ -382,91 +74,16 @@ def is_none_type(type_: Any) -> bool: return False -def display_as_type(v: Type[Any]) -> str: - if ( - not isinstance(v, typing_base) - and not isinstance(v, WithArgsTypes) - and not isinstance(v, type) - ): - v = v.__class__ - - if is_union(get_origin(v)): - return f'Union[{", ".join(map(display_as_type, get_args(v)))}]' - - if isinstance(v, WithArgsTypes): - # Generic alias are constructs like `list[int]` - return str(v).replace("typing.", "") - - try: - return v.__name__ - except AttributeError: - # happens with typing objects - return str(v).replace("typing.", "") - - -def resolve_annotations( - raw_annotations: Dict[str, Type[Any]], module_name: Optional[str] -) -> Dict[str, Type[Any]]: - """ - Partially taken from typing.get_type_hints. - - Resolve string or ForwardRef annotations into type objects if possible. - """ - base_globals: Optional[Dict[str, Any]] = None - if module_name: - try: - module = sys.modules[module_name] - except KeyError: - # happens occasionally, see https://github.com/samuelcolvin/pydantic/issues/2363 - pass - else: - base_globals = module.__dict__ - - annotations = {} - for name, value in raw_annotations.items(): - if isinstance(value, str): - if (3, 10) > sys.version_info >= (3, 9, 8) or sys.version_info >= ( - 3, - 10, - 1, - ): - value = ForwardRef(value, is_argument=False, is_class=True) - elif sys.version_info >= (3, 7): - value = ForwardRef(value, is_argument=False) - else: - value = ForwardRef(value) - try: - value = _eval_type(value, base_globals, None) - except NameError: - # this is ok, it can be fixed with update_forward_refs - pass - annotations[name] = value - return annotations - - def is_callable_type(type_: Type[Any]) -> bool: return type_ is Callable or get_origin(type_) is Callable -if sys.version_info >= (3, 7): +def is_literal_type(type_: Type[Any]) -> bool: + return Literal is not None and get_origin(type_) is Literal - def is_literal_type(type_: Type[Any]) -> bool: - return Literal is not None and get_origin(type_) is Literal - def literal_values(type_: Type[Any]) -> Tuple[Any, ...]: - return get_args(type_) - -else: - - def is_literal_type(type_: Type[Any]) -> bool: - return ( - Literal is not None - and hasattr(type_, "__values__") - and type_ == Literal[type_.__values__] - ) - - def literal_values(type_: Type[Any]) -> Tuple[Any, ...]: - return type_.__values__ +def literal_values(type_: Type[Any]) -> Tuple[Any, ...]: + return get_args(type_) def all_literal_values(type_: Type[Any]) -> Tuple[Any, ...]: @@ -480,151 +97,3 @@ def all_literal_values(type_: Type[Any]) -> Tuple[Any, ...]: values = literal_values(type_) return tuple(x for value in values for x in all_literal_values(value)) - - -def is_namedtuple(type_: Type[Any]) -> bool: - """ - Check if a given class is a named tuple. - It can be either a `typing.NamedTuple` or `collections.namedtuple` - """ - from .utils import lenient_issubclass - - return lenient_issubclass(type_, tuple) and hasattr(type_, "_fields") - - -def is_typeddict(type_: Type[Any]) -> bool: - """ - Check if a given class is a typed dict (from `typing` or `typing_extensions`) - In 3.10, there will be a public method (https://docs.python.org/3.10/library/typing.html#typing.is_typeddict) - """ - from .utils import lenient_issubclass - - return lenient_issubclass(type_, dict) and hasattr(type_, "__total__") - - -test_type = NewType("test_type", str) - - -def is_new_type(type_: Type[Any]) -> bool: - """ - Check whether type_ was created using typing.NewType - """ - return isinstance(type_, test_type.__class__) and hasattr(type_, "__supertype__") # type: ignore - - -def new_type_supertype(type_: Type[Any]) -> Type[Any]: - while hasattr(type_, "__supertype__"): - type_ = type_.__supertype__ - return type_ - - -def _check_classvar(v: Optional[Type[Any]]) -> bool: - if v is None: - return False - - return v.__class__ == ClassVar.__class__ and ( - sys.version_info < (3, 7) or getattr(v, "_name", None) == "ClassVar" - ) - - -def is_classvar(ann_type: Type[Any]) -> bool: - if _check_classvar(ann_type) or _check_classvar(get_origin(ann_type)): - return True - - # this is an ugly workaround for class vars that contain forward references and are therefore themselves - # forward references, see #3679 - if ann_type.__class__ == ForwardRef and ann_type.__forward_arg__.startswith( - "ClassVar[" - ): - return True - - return False - - -# Only in Pydantic -# def update_field_forward_refs(field: "ModelField", globalns: Any, localns: Any) -> None: -# """ -# Try to update ForwardRefs on fields based on this ModelField, globalns and localns. -# """ -# if field.type_.__class__ == ForwardRef: -# field.type_ = evaluate_forwardref(field.type_, globalns, localns or None) -# field.prepare() - -# if field.sub_fields: -# for sub_f in field.sub_fields: -# update_field_forward_refs(sub_f, globalns=globalns, localns=localns) - -# if field.discriminator_key is not None: -# field.prepare_discriminated_union_sub_fields() - - -# Only in Pydantic -# def update_model_forward_refs( -# model: Type[Any], -# fields: Iterable["ModelField"], -# json_encoders: Dict[Union[Type[Any], str], AnyCallable], -# localns: "DictStrAny", -# exc_to_suppress: Tuple[Type[BaseException], ...] = (), -# ) -> None: -# """ -# Try to update model fields ForwardRefs based on model and localns. -# """ -# if model.__module__ in sys.modules: -# globalns = sys.modules[model.__module__].__dict__.copy() -# else: -# globalns = {} - -# globalns.setdefault(model.__name__, model) - -# for f in fields: -# try: -# update_field_forward_refs(f, globalns=globalns, localns=localns) -# except exc_to_suppress: -# pass - -# for key in set(json_encoders.keys()): -# if isinstance(key, str): -# fr: ForwardRef = ForwardRef(key) -# elif isinstance(key, ForwardRef): -# fr = key -# else: -# continue - -# try: -# new_key = evaluate_forwardref(fr, globalns, localns or None) -# except exc_to_suppress: # pragma: no cover -# continue - -# json_encoders[new_key] = json_encoders.pop(key) - - -def get_class(type_: Type[Any]) -> Union[None, bool, Type[Any]]: - """ - Tries to get the class of a Type[T] annotation. Returns True if Type is used - without brackets. Otherwise returns None. - """ - try: - origin = get_origin(type_) - if origin is None: # Python 3.6 - origin = type_ - if issubclass(origin, Type): # type: ignore - if not get_args(type_) or not isinstance(get_args(type_)[0], type): - return True - return get_args(type_)[0] - except (AttributeError, TypeError): - pass - return None - - -def get_sub_types(tp: Any) -> List[Any]: - """ - Return all the types that are allowed by type `tp` - `tp` can be a `Union` of allowed types or an `Annotated` type - """ - origin = get_origin(tp) - if origin is Annotated: - return get_sub_types(get_args(tp)[0]) - elif is_union(origin): - return [x for t in get_args(tp) for x in get_sub_types(t)] - else: - return [tp] diff --git a/typer/core.py b/typer/core.py index 31fece5a76..00e21da869 100644 --- a/typer/core.py +++ b/typer/core.py @@ -31,15 +31,18 @@ else: from typing_extensions import Literal +MarkupMode = Literal["markdown", "rich", None] + try: import rich from . import rich_utils + DEFAULT_MARKUP_MODE: MarkupMode = "rich" + except ImportError: # pragma: no cover rich = None # type: ignore - -MarkupMode = Literal["markdown", "rich", None] + DEFAULT_MARKUP_MODE = None # Copy from click.parser._split_opt @@ -167,6 +170,7 @@ def _main( complete_var: Optional[str] = None, standalone_mode: bool = True, windows_expand_args: bool = True, + rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, **extra: Any, ) -> Any: # Typer override, duplicated from click.main() to handle custom rich exceptions @@ -208,7 +212,7 @@ def _main( if not standalone_mode: raise # Typer override - if rich: + if rich and rich_markup_mode is not None: rich_utils.rich_format_error(e) else: e.show() @@ -238,7 +242,7 @@ def _main( if not standalone_mode: raise # Typer override - if rich: + if rich and rich_markup_mode is not None: rich_utils.rich_abort_error() else: click.echo(_("Aborted!"), file=sys.stderr) @@ -614,7 +618,7 @@ def __init__( hidden: bool = False, deprecated: bool = False, # Rich settings - rich_markup_mode: MarkupMode = None, + rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, rich_help_panel: Union[str, None] = None, ) -> None: super().__init__( @@ -665,11 +669,12 @@ def main( complete_var=complete_var, standalone_mode=standalone_mode, windows_expand_args=windows_expand_args, + rich_markup_mode=self.rich_markup_mode, **extra, ) def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: - if not rich: + if not rich or self.rich_markup_mode is None: return super().format_help(ctx, formatter) return rich_utils.rich_format_help( obj=self, @@ -687,7 +692,7 @@ def __init__( Union[Dict[str, click.Command], Sequence[click.Command]] ] = None, # Rich settings - rich_markup_mode: MarkupMode = None, + rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, rich_help_panel: Union[str, None] = None, **attrs: Any, ) -> None: @@ -727,14 +732,21 @@ def main( complete_var=complete_var, standalone_mode=standalone_mode, windows_expand_args=windows_expand_args, + rich_markup_mode=self.rich_markup_mode, **extra, ) def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: - if not rich: + if not rich or self.rich_markup_mode is None: return super().format_help(ctx, formatter) return rich_utils.rich_format_help( obj=self, ctx=ctx, markup_mode=self.rich_markup_mode, ) + + def list_commands(self, ctx: click.Context) -> List[str]: + """Returns a list of subcommand names. + Note that in Click's Group class, these are sorted. + In Typer, we wish to maintain the original order of creation (cf Issue #933)""" + return [n for n, c in self.commands.items()] diff --git a/typer/main.py b/typer/main.py index 30bfa41fd0..e8440d8ca9 100644 --- a/typer/main.py +++ b/typer/main.py @@ -12,10 +12,18 @@ from uuid import UUID import click +from typing_extensions import get_args, get_origin -from ._typing import get_args, get_origin, is_union +from ._typing import is_union from .completion import get_completion_inspect_parameters -from .core import MarkupMode, TyperArgument, TyperCommand, TyperGroup, TyperOption +from .core import ( + DEFAULT_MARKUP_MODE, + MarkupMode, + TyperArgument, + TyperCommand, + TyperGroup, + TyperOption, +) from .models import ( AnyType, ArgumentInfo, @@ -39,10 +47,11 @@ try: import rich - from rich.console import Console from rich.traceback import Traceback - console_stderr = Console(stderr=True) + from . import rich_utils + + console_stderr = rich_utils._get_rich_console(stderr=True) except ImportError: # pragma: no cover rich = None # type: ignore @@ -70,12 +79,15 @@ def except_hook( supress_internal_dir_names = [typer_path, click_path] exc = exc_value if rich: + from .rich_utils import MAX_WIDTH + rich_tb = Traceback.from_exception( type(exc), exc, exc.__traceback__, show_locals=exception_config.pretty_exceptions_show_locals, suppress=supress_internal_dir_names, + width=MAX_WIDTH, ) console_stderr.print(rich_tb) return @@ -133,7 +145,7 @@ def __init__( deprecated: bool = Default(False), add_completion: bool = True, # Rich settings - rich_markup_mode: MarkupMode = None, + rich_markup_mode: MarkupMode = Default(DEFAULT_MARKUP_MODE), rich_help_panel: Union[str, None] = Default(None), pretty_exceptions_enable: bool = True, pretty_exceptions_show_locals: bool = True, @@ -436,7 +448,6 @@ def solve_typer_info_help(typer_info: TyperInfo) -> str: def solve_typer_info_defaults(typer_info: TyperInfo) -> TyperInfo: values: Dict[str, Any] = {} - name = None for name, value in typer_info.__dict__.items(): # Priority 1: Value was set in app.add_typer() if not isinstance(value, DefaultPlaceholder): @@ -505,7 +516,7 @@ def get_group_from_info( context_param_name, ) = get_params_convertors_ctx_param_name_from_function(solved_info.callback) cls = solved_info.cls or TyperGroup - assert issubclass(cls, TyperGroup) + assert issubclass(cls, TyperGroup), f"{cls} should be a subclass of {TyperGroup}" group = cls( name=solved_info.name or "", commands=commands, @@ -717,9 +728,9 @@ def get_click_type( elif parameter_info.parser is not None: return click.types.FuncParamType(parameter_info.parser) - elif annotation == str: + elif annotation is str: return click.STRING - elif annotation == int: + elif annotation is int: if parameter_info.min is not None or parameter_info.max is not None: min_ = None max_ = None @@ -730,7 +741,7 @@ def get_click_type( return click.IntRange(min=min_, max=max_, clamp=parameter_info.clamp) else: return click.INT - elif annotation == float: + elif annotation is float: if parameter_info.min is not None or parameter_info.max is not None: return click.FloatRange( min=parameter_info.min, @@ -739,7 +750,7 @@ def get_click_type( ) else: return click.FLOAT - elif annotation == bool: + elif annotation is bool: return click.BOOL elif annotation == UUID: return click.UUID @@ -838,7 +849,7 @@ def get_click_param( else: default_value = param.default parameter_info = OptionInfo() - annotation: Any = Any + annotation: Any if not param.annotation == param.empty: annotation = param.annotation else: diff --git a/typer/params.py b/typer/params.py index 176ace2b36..77c86b694e 100644 --- a/typer/params.py +++ b/typer/params.py @@ -69,8 +69,7 @@ def Option( path_type: Union[None, Type[str], Type[bytes]] = None, # Rich settings rich_help_panel: Union[str, None] = None, -) -> Any: - ... +) -> Any: ... # Overload for Option created with custom type 'click_type' @@ -133,8 +132,7 @@ def Option( path_type: Union[None, Type[str], Type[bytes]] = None, # Rich settings rich_help_panel: Union[str, None] = None, -) -> Any: - ... +) -> Any: ... def Option( @@ -307,8 +305,7 @@ def Argument( path_type: Union[None, Type[str], Type[bytes]] = None, # Rich settings rich_help_panel: Union[str, None] = None, -) -> Any: - ... +) -> Any: ... # Overload for Argument created with custom type 'click_type' @@ -363,8 +360,7 @@ def Argument( path_type: Union[None, Type[str], Type[bytes]] = None, # Rich settings rich_help_panel: Union[str, None] = None, -) -> Any: - ... +) -> Any: ... def Argument( diff --git a/typer/rich_utils.py b/typer/rich_utils.py index cf0538e914..905c5a3dbf 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -68,9 +68,9 @@ STYLE_ABORTED = "red" _TERMINAL_WIDTH = getenv("TERMINAL_WIDTH") MAX_WIDTH = int(_TERMINAL_WIDTH) if _TERMINAL_WIDTH else None -COLOR_SYSTEM: Optional[ - Literal["auto", "standard", "256", "truecolor", "windows"] -] = "auto" # Set to None to disable colors +COLOR_SYSTEM: Optional[Literal["auto", "standard", "256", "truecolor", "windows"]] = ( + "auto" # Set to None to disable colors +) _TYPER_FORCE_DISABLE_TERMINAL = getenv("_TYPER_FORCE_DISABLE_TERMINAL") FORCE_TERMINAL = ( True @@ -144,7 +144,7 @@ def _get_rich_console(stderr: bool = False) -> Console: ) -def _make_rich_rext( +def _make_rich_text( *, text: str, style: str = "", markup_mode: MarkupMode ) -> Union[Markdown, Text]: """Take a string, remove indentations, and return styled text. @@ -194,7 +194,7 @@ def _get_help_text( # Remove single linebreaks if markup_mode != MARKUP_MODE_MARKDOWN and not first_line.startswith("\b"): first_line = first_line.replace("\n", " ") - yield _make_rich_rext( + yield _make_rich_text( text=first_line.strip(), style=STYLE_HELPTEXT_FIRST_LINE, markup_mode=markup_mode, @@ -217,7 +217,7 @@ def _get_help_text( # Join with double linebreaks if markdown remaining_lines = "\n\n".join(remaining_paragraphs) - yield _make_rich_rext( + yield _make_rich_text( text=remaining_lines, style=STYLE_HELPTEXT, markup_mode=markup_mode, @@ -272,7 +272,7 @@ def _get_parameter_help( for x in paragraphs ] items.append( - _make_rich_rext( + _make_rich_text( text="\n".join(paragraphs).strip(), style=STYLE_OPTION_HELP, markup_mode=markup_mode, @@ -331,7 +331,7 @@ def _make_command_help( paragraphs[0] = paragraphs[0].replace("\n", " ") elif paragraphs[0].startswith("\b"): paragraphs[0] = paragraphs[0].replace("\b\n", "") - return _make_rich_rext( + return _make_rich_text( text=paragraphs[0].strip(), style=STYLE_OPTION_HELP, markup_mode=markup_mode, @@ -674,7 +674,7 @@ def rich_format_help( # Remove single linebreaks, replace double with single lines = obj.epilog.split("\n\n") epilogue = "\n".join([x.replace("\n", " ").strip() for x in lines]) - epilogue_text = _make_rich_rext(text=epilogue, markup_mode=markup_mode) + epilogue_text = _make_rich_text(text=epilogue, markup_mode=markup_mode) console.print(Padding(Align(epilogue_text, pad=False), 1)) diff --git a/typer/utils.py b/typer/utils.py index 5c0e967a64..93c407447e 100644 --- a/typer/utils.py +++ b/typer/utils.py @@ -3,9 +3,8 @@ from copy import copy from typing import Any, Callable, Dict, List, Tuple, Type, cast -from typing_extensions import Annotated, get_type_hints +from typing_extensions import Annotated, get_args, get_origin, get_type_hints -from ._typing import get_args, get_origin from .models import ArgumentInfo, OptionInfo, ParameterInfo, ParamMeta @@ -96,7 +95,7 @@ def __str__(self) -> str: def _split_annotation_from_typer_annotations( base_annotation: Type[Any], ) -> Tuple[Type[Any], List[ParameterInfo]]: - if get_origin(base_annotation) is not Annotated: # type: ignore + if get_origin(base_annotation) is not Annotated: return base_annotation, [] base_annotation, *maybe_typer_annotations = get_args(base_annotation) return base_annotation, [