04.01.2018       Выпуск 211 (01.01.2018 - 07.01.2018)       Статьи

Мастерство Click: пишем сложные CLI приложения Click

Контекст, под-команды, фильтры

Читать>>




Экспериментальная функция:

Ниже вы видите текст статьи по ссылке. По нему можно быстро понять ссылка достойна прочтения или нет

Просим обратить внимание, что текст по ссылке и здесь может не совпадать.

Mastering Click: Writing Advanced Python Command-Line Apps

How to improve your existing Click Python CLIs with advanced features like sub-commands, user input, parameter types, contexts, and more.

Making advanced Python CLIs with Click

Welcome to the second Click tutorial on how to improve your command-line tools and Python scripts. I’ll show you some more advanced features that help you when things are getting a bit more complex and feature rich in you scripts.

You might wonder why I suggest using Click over argparse or optparse. I don’t think they are bad tools, they both have their place and being part of the standard library gives them a great advantage. However, I do think that Click is much more intuitive and requires less boilerplate code to write clean and easy-to-use command-line clients.

I go into more details about that in the first tutorial and give you a comprehensive introduction to Click as well. I also recommend you to take a look at that if this is the first time you hear the name “Click” so you know the basics. I’ll wait here for you.

Now that we are all starting from a similar knowledge level, let’s grab a cup of tea, glass of water or whatever it is that makes you a happy coder and learner ✨. And then we’ll dive into discovering:

  • how you can read parameter values from environment variables,
  • we’ll then separate functionality into multiple sub-commands
  • and get the user to provide some input data on the command-line.
  • We’ll learn what parameter types are and how you can use them
  • and we’ll look at contexts in Click to share data between commands.

Sounds great? Let’s get right to it then.

Building on our existing Python command-line app

We’ll continue building on top of the example that I introduced in the previous tutorial. Together, we built a simple command-line tool that interacted with the OpenWeatherMap API.

It would print the current weather for a location provided as an argument. Here’s an example:

$ python cli.py --api-key <your-api-key> London
The weather in London right now: light intensity drizzle.

You can see the full source code on Github. As a little reminder, here’s what our final command-line tool looked like:

@click.command()
@click.argument('location')
@click.option(
    '--api-key', '-a',
    help='your API key for the OpenWeatherMap API',
)
def main(location, api_key):
    """
    A little weather tool that shows you the current weather in a LOCATION of
    your choice. Provide the city name and optionally a two-digit country code.
    Here are two examples:
    1. London,UK
    2. Canmore
    You need a valid API key from OpenWeatherMap for the tool to work. You can
    sign up for a free account at https://openweathermap.org/appid.
    """
    weather = current_weather(location, api_key)
    print(f"The weather in {location} right now: {weather}.")


if __name__ == "__main__":
    main()

In this tutorial, we’ll extend the existing tool by adding functionality to store data in a configuration file. You’ll also learn multiple ways to validate user input in your Python command-line apps.

Storing the API key in an environment variable

In the example, we have to specify the API key every time we are calling the command-line tool to access the underlying Web API. That can be pretty annoying. Let’s consider a few options that we have to improve how our tool handles this.

One of the first things that comes to mind is storing the API key in an environment variable in a 12-factor style.

We can then extract the API key from that variable in Python using os.getenv. Try it out yourself:

>>> import os
>>> api_key = os.getenv("API_KEY")
>>> print(api_key)
your-api-key

This works totally fine but it means that we have to manually integrate it with the Click parameter that we already have. Luckily, Click already allows us to provide parameter values as environment variables. We can use envvar in our parameter declaration:

@click.option(
    '--api-key', '-a',
    envvar="API_KEY",
)

That’s all! Click will now use the API key stored in an environment variable called API_KEY and fall back to the --api-key option if the variable is not defined. And since examples speak louder than words, here’s how you’d use the command with an environment variable:

$ export API_KEY="<your-api-key>"
$ python cli.py London
The weather in London right now: light intensity drizzle.

But you can still use the --api-key option with an API key as well:

$ python cli.py --api-key <your-api-key> London
The weather in London right now: light intensity drizzle.

You’re probably wondering about what happens when you have the environment variable defined and also add the option when running the weather tool. The answer is simple: the option beats environment variable.

We have now simplified running our weather command with just adding a single line of code.

Separating functionality into sub-commands

I am sure you agree that we can do better. If you’ve worked with a command-line tool like docker or heroku, you are familiar with how they manage a large set of functionality and handle user authentication.

Let’s take a look at the Heroku Toolbelt. It provides a --help option for more details:

$ heroku --help
Usage: heroku COMMAND

Help topics, type heroku help TOPIC for more details:

 access          manage user access to apps
 addons          tools and services for developing, extending, and operating your app
 apps            manage apps
 auth            heroku authentication
 authorizations  OAuth authorizations
 ... # there's more but we don't care for now

They use a mandatory argument as a new command (also called sub-command) that provides a specific functionality. For example heroku login will authenticate you and store a token in a configuration file if the login is successful.

Wouldn’t it be nice if we could do the same for our weather command? Well, we can! And you’ll see how easy it is as well.

We can use Click’s Commands and Groups to implement our own version of this. And trust me, it sounds more complicated than it actually is.

Let’s start with looking at our weather command and defining the command that we’d like to have. We’ll move the existing functionality into a command and name it current (for the current weather). We’d now run it like this:

$ python cli.py current London
The weather in London right now: light intensity drizzle.

So how can we do this? We start by creating a new entry point for our weather command and registering it as a group:

We have now turned our main function into a command group object that we can use to register new commands “below” it. What that means is, that we change our @click.command decorator to @main.command when wrapping our weather function. We’ll also have to rename the function from main to the name we want to give our command. What we end up with is this:

@main.command()
@click.argument('location')
@click.option(
    '--api-key', '-a',
    help='your API key for the OpenWeatherMap API',
)
def current(location, api_key):
    ...

And I’m sure you’ve already guessed it, this means we know run our command like this:

$ python cli.py current London
The weather in London right now: light intensity drizzle.

Storing the API key in a configuration file using another sub-command

The change we made above obviously doesn’t make sense on its own. What we wanted to add is a way to store an API key in a configuration file, using a separate command. I suggest we call it config and make it ask the user to enter their API key:

$ python cli.py config
Please enter your API key []: your-api-key

We’ll then store the key in a config file that we’ll put into the user’s home directory: e.g. $HOME/.weather.cfg for UNIX-based systems.

We start with adding a new function to our Python module with the same name as our command and register it with our main command group:

@main.command()
def config():
    """
    Store configuration values in a file.
    """
    print("I handle the configuration.")

You can now run that new command and it will print the statement above.

$ python cli.py config
I handle the configuration.

Boom, we’ve now extended our weather tool with two separate commands:

Asking the user for command-line input

We created a new command but it doesn’t to anything, yet. What we need is the API key from the user, so we can store it in our config file. Let’s start using the --api-key option on our config command and write it to the configuration file.

@main.command()
@click.option(
    '--api-key', '-a',
    help='your API key for the OpenWeatherMap API',
)
def config(api_key):
    """
    Store configuration values in a file.
    """
    config_file = os.path.expanduser('~/.weather.cfg')

    with open(config_file, 'w') as cfg:
        cfg.write(api_key)

We are now storing the API key provided by the user in our config file. But how can we ask the user for their API key like I showed you above? By using the aptly named click.prompt.

@click.option(
    '--api-key', '-a',
    help='your API key for the OpenWeatherMap API',
)
def config(api_key):
    """
    Store configuration values in a file.
    """
    config_file = os.path.expanduser('~/.weather.cfg')

    api_key = click.prompt(
        "Please enter your API key",
        default=api_key
    )

    with open(config_file, 'w') as cfg:
        cfg.write(api_key)

Isn’t it amazing how simple that was? This is all we need to have our config command print out the question asking the user for their API key and receiving it as the value of api_key when the user hits [Enter].

We also continue to allow the --api-key option and use it as the default value for the prompt which means the user can simply hit [Enter] to confirm it:

$ python cli.py config --api-key your-api-key
Please enter your API key [your-api-key]:

That’s a lot of new functionality but the code required is minimal. I’m sure you agree that this is awesome!

Introducing Click’s parameter types

Until now, we’ve basically ignored what kind of input we receive from a user. By default, Click assumes a string and doesn’t really care about anything beyond that. That makes it simple but also means we can get a lot of 🚮.

You probably guessed it, Click also has a solution for that. Actually there are multiple ways of handling input but we’ll be looking at Parameter Types for now.

The name gives a pretty good clue at what it does, it allows us to define a the type of our parameters. The most obvious ones are the builtin Python types such as str, int, float but Click also provides additional types: Path, File and more. The complete list is available in the section on Parameter Types.

Ensuring that an input value is of a specific type is as easy as you can make it. You simply pass the parameter type you’re expecting to the decorator as type argument when defining your parameter. Something like this:

@click.option('--api-key', '-a', type=str)
@click.option('--config-file', '-c', type=click.Path())

Looking at our API key, we expect a string of 32 hexadecimal characters. Take a moment to look at this Wikipedia article if that doesn’t mean anything to you or believe me when I say it means each character is a number between 0 and 9 or a letter between a and f.

There’s a parameter type for that, you ask? No, there is not. We’ll have to build our own. And like everything else, it’ll be super easy (I feel like a broken record by now 😇).

Building a custom parameter type to validate user input

What do we need implement our own parameter type? We have to do two things: (1) we define a new Python class derived from click.ParamType and (2) implement it’s convert method. Classes and inheritance might be a new thing for you, so make sure you understand the benefits of using classes and are familiar with Object-Oriented Programming.

Back to implementing our own parameter type. Let’s call it ApiKey and start with the basic boilerplate:

class ApiKey(click.ParamType):

    def convert(self, value, param, ctx):
        return value

The only thing that should need some more explanation is the list of arguments expected by the convert method. Why are there three of them (in addition to self) and where do they come from?

When we use our ApiKey as the type for our parameter, Click will call the convert method on it and pass the user’s input as the value argument. param will contain the parameter that we declared using the click.option or click.argument decorators. And finally, ctx refers to the context of the command which is something that we’ll be talking about later in this tutorial.

The last thing to note is the return value. Click expects us to either return the cleaned and validated value for the parameter or raise an exception if the value is not valid. If we raise an exception, Click will automatically abort and tell the user that their value is not of the correct type. Sweet, right?

That’s been a lot of talk and no code, so let’s stop here, take a deep breath and look at the implementation.

import re

class ApiKey(click.ParamType):
    name = 'api-key'

    def convert(self, value, param, ctx):
        found = re.match(r'[0-9a-f]{32}', value)

        if not found:
            self.fail(
                f'{value} is not a 32-character hexadecimal string',
                param,
                ctx,
            )

        return value

You can see that we’re only interested in the value of our parameter. We use a regular expression to check for a string of 32 hexadecimal characters. I won’t go into details on regular expressions here but Al Sweigart does in this PyCon video.

Applying a re.match will return a match object for a perfect match or None otherwise. We check if they match and return the unchanged value or call the fail() method provided by Click to explain why the value is incorrect.

Almost done. All we have to do now is plug this new parameter type into our existing config command.

@main.command()
@click.option(
    '--api-key', '-a',
    type=ApiKey(),
    help='your API key for the OpenWeatherMap API',
)
def config(api_key):
    ...

And we are done! A user will now get an error if their API key is in the wrong format and we can put an end to those sleepless nights 🤣.

$ python cli.py config --api-key invalid
Usage: cli.py [OPTIONS] COMMAND [ARGS]...

Error: Invalid value for "--api-key" / "-a": your-api-key is not a 32-character hexadecimal string

I’ve thrown a lot of information at you. I have one more thing that I’d like to show you before we end this tutorial. But if you need a quick break, go get yourself a delicious beverage, hot or cold, and continue reading when you feel refreshed. I’ll go get myself a ☕️ and be right back…

Using the Click context to pass parameters between commands

Alright, welcome back 😉. You probably thought about the command we created, our new API key option and wondered if this means we actually have to define the option on both of our commands, config and current. And your assumption would be correct. Before your eyes pop out and you shout at me “Hell no! I like my code DRY!”, there’s a better way to do this. And if DRY doesn’t mean anything to you, check out this Wikipedia arcticle on the “Don’t Repeat Yourself” principle.

How can we avoid defining the same option on both commands? We use a feature called the “Context”. Click executes every command within a context that carries the definition of the command as well as the input provided by the user. And it comes with a placeholder object called obj, that we can use to pass arbitrary data around between commands.

First let’s look at our group and how we can get access to the context of our main entrypoint:

@click.group()
@click.pass_context
def main(ctx):
   ctx.obj = {}

What we are doing here is telling Click that we want access to the context of the command (or group) and Click will pass it to our function as the first argument, I called it ctx. In the function itself, we can now set the obj attribute on the context to an empty dictionary that we can then fill with data. obj can also be an instance of a custom class that we implement but let’s keep it simple. You can imagine how flexible this is. The only thing you can’t do, is assign your data to anything but ctx.obj.

Now that we have access to the context, we can move our option --api-key to the main function and then save then store the API key in the context:

@click.group()
@click.option(
    '--api-key', '-a',
    type=ApiKey(),
    help='your API key for the OpenWeatherMap API',
)
@click.pass_context
def main(ctx, api_key):
    ctx.obj = {
        'api_key': api_key,
    }

I should mention that it doesn’t matter where you put the click.pass_context decorator, the context will always be the first argument. And with the API key stored in the context, we can now get access to it in both of our commands by adding the pass_context decorator as well:

@main.command()
@click.pass_context
def config(ctx):
    api_key = ctx.obj['api_key']
    ...

The only thing this changes for the user, is that the --api-key option has to come before the config or current commands. Why? Because the option is no associated with the main entry point and not with the sub-commands:

$ python cli.py --api-key your-api-key current Canmore
The weather in Canmore right now: overcast clouds.

I think that’s a small price to pay for keeping our code DRY. And even if you disagree with me, you still learned how the Click context can be used for sharing data between commands; that’s all I wanted anyways 😇.

Advanced Python CLIs with Click — Summary

Wow, we work though a lot of topics. You should have an even better knowledge of Click and it features now. Specifically we looked at:

  • How to read parameter values from environment variables.
  • How you can separate functionality into separate commands.
  • How to ask the user for input on the command-line.
  • What parameter types are in Click and how you can use them for input validation.
  • How Click contexts can help you share data between commands.

I am tempted to call you a Master of Click 🏆 with all of the knowledge you have now. At this point, there should be little that you don’t know how to do. So start playing around with what you learned and improve you own command-line tools. Then come back for another tutorial on testing and packaging of Click commands.

Full code example

import re
import os
import click
import requests

SAMPLE_API_KEY = 'b1b15e88fa797225412429c1c50c122a1'


class ApiKey(click.ParamType):
    name = 'api-key'

    def convert(self, value, param, ctx):
        found = re.match(r'[0-9a-f]{32}', value)

        if not found:
            self.fail(
                f'{value} is not a 32-character hexadecimal string',
                param,
                ctx,
            )

        return value


def current_weather(location, api_key=SAMPLE_API_KEY):
    url = 'https://api.openweathermap.org/data/2.5/weather'

    query_params = {
        'q': location,
        'appid': api_key,
    }

    response = requests.get(url, params=query_params)

    return response.json()['weather'][0]['description']


@click.group()
@click.option(
    '--api-key', '-a',
    type=ApiKey(),
    help='your API key for the OpenWeatherMap API',
)
@click.option(
    '--config-file', '-c',
    type=click.Path(),
    default='~/.weather.cfg',
)
@click.pass_context
def main(ctx, api_key, config_file):
    """
    A little weather tool that shows you the current weather in a LOCATION of
    your choice. Provide the city name and optionally a two-digit country code.
    Here are two examples:
    1. London,UK
    2. Canmore
    You need a valid API key from OpenWeatherMap for the tool to work. You can
    sign up for a free account at https://openweathermap.org/appid.
    """
    filename = os.path.expanduser(config_file)

    if not api_key and os.path.exists(filename):
        with open(filename) as cfg:
            api_key = cfg.read()

    ctx.obj = {
        'api_key': api_key,
        'config_file': filename,
    }


@main.command()
@click.pass_context
def config(ctx):
    """
    Store configuration values in a file, e.g. the API key for OpenWeatherMap.
    """
    config_file = ctx.obj['config_file']

    api_key = click.prompt(
        "Please enter your API key",
        default=ctx.obj.get('api_key', '')
    )

    with open(config_file, 'w') as cfg:
        cfg.write(api_key)


@main.command()
@click.argument('location')
@click.pass_context
def current(ctx, location):
    """
    Show the current weather for a location using OpenWeatherMap data.
    """
    api_key = ctx.obj['api_key']

    weather = current_weather(location, api_key)
    print(f"The weather in {location} right now: {weather}.")


if __name__ == "__main__":
    main()


Лучшая Python рассылка




Разместим вашу рекламу

Пиши: mail@pythondigest.ru

Нашли опечатку?

Выделите фрагмент и отправьте нажатием Ctrl+Enter.

Система Orphus