Typer
Typer is a modern CLI framework for building command-line applications with Python. It is lightweight, highly efficient, and easy to use.
Typer allows you to define command-line interfaces using Python's type annotations. It automatically generates help messages, argument parsing, and command completion. This makes it easy to create complex command-line applications with minimal code.
Key features
- Automatic Help Generation: Typer automatically generates help messages based on the definition of your command-line application. It provides a detailed and easy-to-understand help message to assist users with your application's functionality.
- Argument Parsing: Typer takes care of parsing command-line arguments and options, allowing you to focus on the logic of your application. It provides a simple and intuitive syntax for defining arguments and options, and supports various types, including integers, floats, booleans, and more.
- Type Annotations: Typer leverages Python's type hinting system to define command-line interfaces. This means you can use type annotations to define the expected types of arguments and options. Typer then automatically performs type checking and validation, ensuring that the input provided by the user is of the correct type.
- Interactive Completion: Typer provides interactive completion for commands, arguments, and options. This allows users to easily explore your application's functionality and available options by pressing the Tab key.
- Shell Completion: Typer can automatically generate shell completion scripts for popular shells like Bash, Zsh, and Fish. This makes it easier for users to interact with your command-line application by providing auto-completion support within their preferred shell environment.
Hax
Hax CLI is a simple CLI interface for the functionality of the hax project.
src/cli/ask.py
"""CLI command to ask a question."""
from hax import ai
from langchain.prompts import PromptTemplate
from typer import Typer
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
@app.command("q", hidden=True)
@app.command(rich_help_panel="LLM", help="Ask OpenAI a question [[cyan1]q[/]]")
def question():
"""Ask OpenAI a question."""
question_prompt = input("Question: ")
answer = ai.openai_llm_predict(question=question_prompt)
print(answer)
@app.command("qt", hidden=True)
@app.command(rich_help_panel="LLM", help="Ask OpenAI a question with a template [[cyan1]qt[/]]")
def question_with_template(template_name: str):
"""Ask OpenAI a question with a template."""
match template_name:
case "companyname":
prompt = PromptTemplate.from_template("What is a good name for a company that makes {product}?")
product_name = input("Product name: ")
question_prompt = prompt.format(product=product_name)
answer = ai.openai_llm_predict(question=question_prompt)
print(answer)
@app.command("c", hidden=True)
@app.command(rich_help_panel="Chat", help="Chat with OpenAI. Quit with 'CTRL-c'. [[cyan1]c[/]]")
def chat():
"""Chat with OpenAI."""
messages = []
while True:
message = input("Message: ")
messages.append(message)
answer = ai.openai_chat_predict(messages=messages)
print(answer)
src/cli/config.py
"""CLI module for configuration."""
import subprocess as sup
from hax import log
from hax.conf import Deps
from hax.file import File
from hax.cbs import Azure, CBSFiles
from rich.prompt import Prompt
from typer import Argument, Typer
from typing_extensions import Annotated
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
class Completion:
"""Provide completion of CLI command values."""
@staticmethod
def az():
"""Completion for az."""
return ["acrlogin", "cli", "login", "node"]
@app.command(rich_help_panel="Configure")
def az(name: Annotated[str, Argument(help="Configure: 'cli'")] = "cli"):
"""Azure related configuration."""
match name:
case "acrlogin":
sup.run("az acr login --name crcapdevaksclu01", shell=True)
case "cli":
cbs_cert_file = CBSFiles.ot / "build" / "ca-certificates.pem"
az_cert_file = (
File.hbrew_cellar
/ "azure-cli/"
/ Deps.az_cli_version
/ "libexec"
/ "lib"
/ "python3.11"
/ "site-packages"
/ "certifi"
/ "cacert.pem"
)
cbs_certs = cbs_cert_file.read_text()
log.info(
f"Adding self-signed certs to az certifi:\n\n{az_cert_file.read_text()}"
)
with open(az_cert_file, "a") as f:
f.write(cbs_certs)
log.debug(f"Updated cacert.pm file contents:\n\n{az_cert_file.read_text()}")
case "login":
cluster = Prompt.ask("Cluster name:", default="crcapdevaksclu01")
Azure.login(cluster=cluster)
case "node":
cbs_cert_file = CBSFiles.ot / "build" / "ca-certificates.pem"
sup.run(f"npm config set cafile {cbs_cert_file}", shell=True)
case _:
log.warning(f'Setup argument not found: "{name}".')
src/cli/copy.py
"""CLI command to copy."""
from hax import log
from hax.cbs import CBSFiles
from typer import Argument, Typer
from typing_extensions import Annotated
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
@app.command(rich_help_panel="Copy")
def cbs(name: Annotated[str, Argument(help="Files to copy.")] = "prod"):
"""Copy CBS files. Options are "prod"."""
if name == "prod":
CBSFiles.copy_prod()
else:
log.warning(f"Unknown argument: {name}")
src/cli/get.py
"""CLI command to get stuff."""
import subprocess as sup
from typer import Typer
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
@app.command("yt", hidden=True)
@app.command(rich_help_panel="Get", help="YouTube video [[cyan1]yt[/]]")
def youtube(video: str):
"""Download YouTube video"""
sup.run(f"youtube-dl -x \
--no-check-certificate \
--embed-thumbnail \
--add-metadata \
--audio-format m4a \
--audio-quality 0 \
-o '~/Desktop/%(title)s.%(ext)s' {video}", shell=True)
src/cli/info.py
"""CLI command to copy."""
from bacore.domain import files
from bacore.interactors import retrieve
from hax import console_handler
from pathlib import Path
from typer import Typer
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
pyproject_file = Path.home() / "Repos" / "hax" / "pyproject.toml"
pyproject_toml_object = files.TOML(path=pyproject_file)
pyproject_file_class_object = retrieve.File(file=pyproject_toml_object)
project_info = retrieve.ProjectInfo(project_file=pyproject_file_class_object)
@app.command(rich_help_panel="Info about")
def project():
"""Show information about project."""
if console_handler.level >= 30: # WARNING
print(f"Project version: {project_info.version}")
elif console_handler.level >= 20: # INFO
print(
f"Project version: {project_info.version}, Project name: {project_info.name}"
)
else: # DEBUG
print(project_info.name, project_info.version, project_info.__mro__)
src/cli/install.py
"""CLI command to copy."""
import subprocess as sup
from hax import log
from hax.cli import upgrade
from hax.conf import Project
from os import getenv
from shutil import which
from typer import Abort, Argument, Exit, Typer
from typing import Annotated
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
@app.command(rich_help_panel="Bootstrap", help="Flux in given directory with GitHub as remote")
def flux(repo: Annotated[str, Argument(help="Flux repository name")],
user: Annotated[str, Argument(help="GitHub user")] = getenv('GITHUB_USER'),
branch: Annotated[str, Argument(help="Branch name")] = "main"
):
"""Bootstrap [Flux](https://fluxcd.io) in given directory using GitHub as remote.
The flux bootstrap script is idempotent ("can be run many times without causing any harm - probably").
"""
assert getenv('GITHUB_USER') is not None, "Value of env var GITHUB_USER should not be Null."
assert getenv('GITHUB_TOKEN') is not None, "Value of env var GITHUB_TOKEN should not be Null."
if which("flux") is None:
log.info("Flux is not installed. Installing...")
if Project.os == "Darwin":
log.debug("Installing fluxcd with Homebrew.")
sup.run("brew install fluxcd/tap/flux")
assert which("flux") is not None, "Flux is (still) not installed."
else:
log.warning("Only MacOS has install method for 'fluxcd' set up at the moment.")
raise Abort() # Could do this: sup.run("curl -s https://fluxcd.io/install.sh | sudo bash")
flux_prerequisities_are_met = sup.run("flux check --pre", shell=True, capture_output=True, check=True)
if flux_prerequisities_are_met.returncode != 0:
log.error("Prerequisites for running flux are not met. Please fix the errors and try again.")
raise Exit(1)
else:
log.info("Prerequisites for running flux are met. Continuing...")
sup.run(f"flux bootstrap github \
--owner={user} \
--repository={repo} \
--branch={branch} \
--path=./clusters/{repo} \
--personal", shell=True, check=True)
@app.command(rich_help_panel="Install", help="Homebrew on a Mac")
def homebrew():
"""[Homebrew](https://brew.sh)"""
sup.run("/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"")
upgrade.homebrew()
@app.command(rich_help_panel="Install", help="TailwindCSS in current directory")
def tailwindcss():
"""Install [TailwindCSS](https://tailwindcss.com) in the current directory.
These are configurations which I'm not using right now: @latest postcss@latest autoprefixer@latest.
If ssl error: npm config set strict-ssl false
Perhaps running with "-p" flag for `postcss` is a good idea.
"""
log.info("Installing TailwindCSS in the current directory.")
sup.run("npm install -D tailwindcss", shell=True)
sup.run("npx tailwindcss init", shell=True)
src/cli/main.py
The main file contains the main()
function which is used for callback
to provide the verbose
and very_verbose
command options.
The app.addtyper() is used to group the commands into "verbs".
"""Provide CLI for all hax functionality."""
from hax import console_handler
from hax.cli import ask, config, copy, get, info, install, mount, publish, start, test, upgrade, watch
from typer import Option, Typer
from typing import Annotated
def main(
verbose: Annotated[
bool, Option("--verbose", "-v", help="Verbose output ([blue]INFO[/] level)")] = False,
very_verbose: Annotated[
bool, Option("--very-verbose", "-vv", help="Very verbose output ([green]DEBUG[/] level)",),]
= False,
):
"""Entry point for CLI."""
if verbose:
console_handler.setLevel(20) # INFO
elif very_verbose:
console_handler.setLevel(10) # DEBUG
else:
console_handler.setLevel(30) # WARNING
app = Typer(rich_markup_mode="rich", add_completion=False, callback=main, help="Enter command...")
app.add_typer(ask.app, name="a", callback=main, help="Ask")
app.add_typer(config.app, name="co", callback=main, help="Configure")
app.add_typer(copy.app, name="c", callback=main, help="Copy")
app.add_typer(get.app, name="g", callback=main, help="Get")
app.add_typer(info.app, name="i", callback=main, help="Info")
app.add_typer(install.app, name="in", callback=main, help="Install")
app.add_typer(mount.app, name="m", callback=main, help="Mount")
app.add_typer(publish.app, name="p", callback=main, help="Publish")
app.add_typer(start.app, name="s", callback=main, help="Start")
app.add_typer(test.app, name="t", callback=main, help="Test")
app.add_typer(upgrade.app, name="u", callback=main, help="Upgrade")
app.add_typer(watch.app, name="w", callback=main, help="Watch")
state = {"verbose": False}
src/cli/mount.py
"""CLI command for mounting."""
from hax import log
from hax.cbs import CBSFiles
from typer import Argument, Typer
from typing_extensions import Annotated
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
@app.command(rich_help_panel="Mount")
def cbs(name: Annotated[str, Argument(help="Mount CBS smb shares.")] = "prod"):
"""Mount files and folders."""
if name == "prod":
CBSFiles.mount()
else:
log.warning(f"Unknown argument: {name}")
src/cli/start.py
"""CLI command to run things."""
import subprocess as sup
from hax import log
from hax.cli import publish, test
from hax.conf import Bojort
from hax.docker import Container
from hax.cbs import CBSFiles
from typer import Argument, Typer
from typing import Annotated
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
class Completion:
"""Provide completion of CLI start commands.
Completions should be provided in a function. The function should return a list of tuples with short name and
description. (The description is not used in the completion, but is used in the help text in the CLI.)
Todo:
- Rewrite to make the class callable.
"""
@staticmethod
def items(function):
"""Return completion items without description. Used for auto-completion."""
return ', '.join([item for (item, description) in function()])
@staticmethod
def bojort():
"""Completion for Bojort."""
return [
("main", "Start site, open pages, start tailwindcss, run tests."),
("tailwind", "Start tailwindcss")
]
@staticmethod
def container():
"""Completion for container."""
return [
("bdev", "Bojort dev"),
("otdev", "OneTester dev"),
("otdevb", "OneTester dev with bash"),
("otdevp", "OneTester dev with ipython"),
("otdevt", "OneTester dev with command 'ot test'"),
("otj", "OneTester dev with Jupyter Lab"),
("otmkdocs", "OneTester mkdocs"),
("otmkdocsb", "OneTester mkdocs with bash")
]
@staticmethod
def tailwindcss():
"""Completion for tailwindcss."""
return [
("bojort", "Bojort.com"),
("gorg", "Grönberg.org"),
("onefront", "OneFront.crosskey.fi")
]
@staticmethod
def website():
"""Completion for site."""
return [
("gorg", "Grönberg.org")
]
@app.command(rich_help_panel="Start")
def bojort(name: Annotated[
str, Argument(help=f"Service: [blue]{Completion.items(Completion.bojort)}[/blue]",
rich_help_panel="Start Bojort",
autocompletion=Completion.bojort)] = "main"):
"""Bojort application."""
match name:
case "main":
Container.run(repo=Bojort.path, container="app", ports=(8010, 8010), command="b start site", detached=True)
sup.run("open http://127.0.0.1:8010", shell=True)
sup.run("open http://127.0.0.1:8010/docs", shell=True)
test.bojort()
case "tailwind":
breakpoint()
sup.run("cd ~/Repos/bojort/tailwindcss && \
npx tailwindcss -i ./styles/input.css -o ../site/static/css/main.css --watch", shell=True)
@app.command(rich_help_panel="Start")
def cbs():
"""Crosskey workflows"""
# sup.run("say 'Starting Crosskey workflows'", shell=True)
log.info("Starting Crosskey workflows")
sup.run("xhost +localhost", shell=True) # Allow X11 forwarding
# CBSFiles.mount()
sup.run("docker container prune -f", shell=True) # Prune containers
sup.run("docker image prune -f", shell=True) # Prune images
sup.run("docker volume prune -f", shell=True) # Prune volumes
CBSFiles.copy_prod()
# Container.run(repo=CBSFiles.otof, container="dev", command="ot test all")
Container.run(repo=CBSFiles.ot, container="docs", command="ot publish documentation")
# Container.run(repo=CBSFiles.otof, container="docs", command="ot publish documentation")
container(name="otdev")
sup.run("open http://127.0.0.1:8000", shell=True)
container(name="otdocs")
sup.run("open http://127.0.0.1:8041", shell=True)
container(name="ottestreport")
sup.run("open http://127.0.0.1:8042", shell=True)
publish.onetester()
# container(name="ottestreport")
test.onetester()
@app.command("c", rich_help_panel="Start")
def container(name: Annotated[
str, Argument(help=f"Name of container: [blue]{Completion.items(Completion.container)}[/blue]",
rich_help_panel="Start container",
autocompletion=Completion.container)]):
"""Container..."""
match name:
case "bdev":
Container.run(repo=Bojort.path, container="dev", command="bash")
case "otapp":
Container.run(repo=CBSFiles.ot, container="app")
case "otdev":
Container.run(repo=CBSFiles.ot, container="dev")
case "otdevb":
Container.run(repo=CBSFiles.ot, container="dev", command="bash")
case "otdevp":
Container.run(repo=CBSFiles.ot, container="dev", command="ipython")
case "otdocs":
Container.run(repo=CBSFiles.ot, container="docs")
case "ottestreport":
Container.run(repo=CBSFiles.ot, container="testreport")
case _:
log.error(f"Unknown container: {name}")
@app.command("j", rich_help_panel="Start")
def jupyter():
"""Jupyter Lab"""
log.info("Starting Jupyter Lab")
sup.run("cd ~/Documents && jupyter-lab --port=9036 &", shell=True)
@app.command(rich_help_panel="Start")
def site():
"""Local site..."""
sup.run("uvicorn bojort.main:app --host 0.0.0.0 --port 8010 --reload", shell=True)
@app.command(rich_help_panel="Start")
def tailwindcss(site: Annotated[str, Argument(
help=f"Site name: [blue]{Completion.items(Completion.tailwindcss)}[/]",
rich_help_panel="Start tailwindcss",
autocompletion=Completion.tailwindcss
)]):
"""TailwindCSS"""
match site:
case "bojort":
sup.run(f"cd {Bojort.path}/tailwindcss && \
npx tailwindcss -i ./styles/input.css -o ../site/static/css/main.css --watch", shell=True)
case "gorg":
sup.run(f"cd ~/Documents/.style/ && \
npx tailwindcss -i input.css -o ../../Public/gorg/style/main.css --watch", shell=True)
case "onefront":
sup.run(f"cd {CBSFiles.ot}/tailwindcss && \
npx tailwindcss -i ./styles/input.css -o ../site/static/css/main.css --watch", shell=True)
case _:
log.error(f"Unknown site: {site}")
@app.command(rich_help_panel="Start")
def website(name: Annotated[str,
Argument(help=f"Name of site: [blue]{Completion.items(Completion.website)}[/blue]",
rich_help_panel="Start site",
autocompletion=Completion.website)]):
"""Local site..."""
match name:
case "gorg":
sup.run("cd ~/Documents && mkdocs serve -a 127.0.0.1:8001 &", shell=True)
case _:
log.error(f"Unknown site: {name}")
src/cli/test.py
"""CLI command to run tests."""
import subprocess as sup
from hax import log
from hax.conf import Bojort
from hax.docker import Container
from hax.cbs import CBSFiles
from typer import Option, Typer
from typing_extensions import Annotated
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
@app.command(rich_help_panel="Test")
def bacore():
"""Run pytest for bacore."""
sup.run("cd ~/Repos/bacore && source venv/bin/activate && ptw -cw src tests -- -s", shell=True)
@app.command(rich_help_panel="Test")
def bojort():
"""Run test suit Bojort."""
Container.run(repo=Bojort.path, container="dev", command="b test")
@app.command(rich_help_panel="Test")
def hax(headed: Annotated[bool, Option(help="Run in headed mode.")] = False,
memray: Annotated[bool, Option(help="Inspect load on memory usage while running tests.")] = False):
"""Run pytest for hax.
Possibility to run "headed" UI tests in a browser.
Tools:
- [pytest-testmon](https://pypi.org/project/pytest-testmon/)
- [pytest-watch](https://pypi.org/project/pytest-watch/)
"""
log.debug("Running pytest with headed={headed} and memray={memray}.")
if headed:
if memray:
sup.run("cd ~/Documents && ptw -cw src tests -- --headed --memray -s", shell=True)
else:
sup.run("cd ~/Documents && ptw -cw src tests -- --headed -s", shell=True)
else:
if memray:
sup.run("cd ~/Documents && ptw -cw src tests -- --memray -s", shell=True)
else:
sup.run("cd ~/Documents && ptw -cw src tests -- -s", shell=True)
@app.command(rich_help_panel="Test")
def onetester():
"""Run tests for OneTester inside of Docker container (locally)."""
Container.run(repo=CBSFiles.ot, container="dev", command="ot test onetester")
Container.run(repo=CBSFiles.otof, container="dev", command="ot test ci --headed")
How do I run pytest from code and then specify a particular repository to test in? Below is the code I have so far.
@app.command(richhelppanel="Test") def testbacore(): """Run pytest for bacore.""" retcode = pytest.main(["-s", "src", "tests"]) return retcode
[AI]: To run pytest from code and specify a particular repository to test, you can update your code as follows:
```python import pytest
@app.command(richhelppanel="Test") def testbacore(): """Run pytest for bacore.""" repository = "path/to/repository" # specify the repository directory retcode = pytest.main(["-s", f"{repository}/src", f"{repository}/tests"]) return retcode ```
By specifying the `repository` variable with the path to the desired repository, you can pass that path to pytest as the directory for both the source code (`src`) and the tests directory (`tests`).
[ME]:
src/cli/upgrade.py
"""CLI test commands."""
import subprocess as sup
from typer import Argument, Typer
from typing_extensions import Annotated
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
@app.command(rich_help_panel="Upgrade")
def homebrew():
"""Upgrade Homebrew and all packages installed with it."""
sup.run("brew update && brew upgrade && brew cleanup && brew doctor", shell=True)
@app.command(rich_help_panel="Upgrade")
def emacs(uninstall: Annotated[int, Argument(help="Emacs version to uninstall.")] = 29,
install: Annotated[int, Argument(help="Emacs version to install.")] = 29):
"""Emacs"""
sup.run(f"brew uninstall emacs-plus@{uninstall}", shell=True)
sup.run(f"brew install emacs-plus@{install} \
--with-dbus \
--with-mailutils \
--with-no-frame-refocus \
--with-imagemagic \
--with-native-comp", shell=True)
src/cli/watch.py
"""CLI command to get stuff."""
import subprocess as sup
from typer import Typer
app = Typer(rich_markup_mode="rich")
state = {"verbose": False}
@app.command(rich_help_panel="Watch")
def bojort():
"""Bojort kustomizations"""
sup.run("cd ~/Repos/bojort-flux && \
watch -n 10 flux get kustomizations", shell=True)