Creating CLI tool in Python - part 2
In the previous post, I started to create a CLI tool to automate some of my blog post workflow. I managed to set-up a new project and make a CLI tool to print "Hello World!".
In this post I'm going to add the first command to the CLI tool, called "blog" and this will require an argument called "title". I will also add validation for this command argument and take a look at unit testing.
Overview
In general, the whole business logic of the application will look like in the following schema.
To start an application user will have to type on the command line following command:
1assistant blog "Title of the blog" "https://unsplash.com/photos/9FvZfRKKfH8"
This will trigger blog command validation and code will now validate the title. Probably there will be that title is required and should have some minimum length, so validation logic will look like that:
- If the title is not valid it will throw exception and exit.
- If the title is valid, it will validate an Unsplash image URL.
Now, when validating an image URL, probably will need to check that string matches some URL pattern and contains a work "unsplash".
Image validation logic will look like that:
- If the image URL not valid create a template and exit application.
- If the image URL is valid, download image, get image data, create a template and exit.
On a high level, I think that's all. I'm sure there will be some changes coming once I start to implement it, but at least this is a minimum requirement.
Since there is quite a lot to implement, I will stick to the plan explained in an introduction of this blog post.
Project structure
1assistant/2|-- README.md3|-- assistant.py4|-- install-dev.sh5|-- logger.py6|-- setup.py7|-- str_helper.py8|-- test_str_helper.py9|-- test_validator.py10|-- validator.py
assistant.py
In a previous post I defined that calling cli() method will print "Hello World!", in this post, I have defined a verbose option, "blog" command and "title" attribute that can be passed in.
At the beginning of the file I'm importing a modules click, logger and validator - logger and validator are modules I created for a better code structure.
1import click2import logger3import validator
Then I defined a Config object that will be used for options transfer between commands, one of the options is a verbose flag. Using click.make_pass_decorator() the command I create a callback that will return this object and make sure it's initialized.
1class Config(object):23 def __init__(self):4 self.verbose = False56pass_config = click.make_pass_decorator(Config, ensure=True)
Program entrypoint will still be cli() function. I assign a verbose option for this command and in this command, I Initialize options values to config objects.
Using click.group() function I can define that this command can have subcommands, in my case it's a "blog" command.
1@click.group()2@click.option(3 '-v',4 '--verbose',5 is_flag=True,6 help='Will print verbose messages about processes.'7)8@pass_config9def cli(config, verbose):10 config.verbose = verbose
And now I defined a subcommand "blog", for this, I use command() method. Subcommand method needs to be chained to a parent command - like this cli.command().
This command will take the title as an argument. To define an argument for the command I use click.argument() method.
And in "blog" command, I added a description of the command, do validation for a "title" argument and write a result to the command line. In the future, this command will do more things, but for now, it's fine.
1@cli.command()2@click.argument('title')3@pass_config4def blog(config, title):5 """Use this command to interact with blog."""6 try:7 logger.info(config.verbose, 'Starting title validation.')8 title_validation_result = validator.validate_tile(title)9 logger.success(title_validation_result)10 except ValueError as er:11 logger.error('Validation Error: {}'.format(er))12 except Exception as ex:13 logger.error(format(ex))
And finally I had to register a "blog" command as a sub command of cli. This can be done like this:
1cli.add_command(blog)
logger.py
This module is a wrapper around click.echo() method. All it does is just give me a cleaner way to output error, success, and info to a command line.
1from click import echo, style23def info(isVerbose, text):4 """Log information to the command line if verbose is enabled."""5 if isVerbose:6 echo(text)78def error(text):9 """Log error message to the command line."""10 echo(style(text, fg='red'))1112def success(text):13 """Log success message to command line."""14 echo(style(text, fg='green'))
str_helper.py
This module will contain a string related helper functions. Currently, I have defined only one function that checks if a string is null or whitespace. I'm using this function for validation.
1def is_null_or_whitespace(str):2 """Check if string is null or whitespace."""3 if str and str.strip():4 return False5 else:6 return True
validator.py
This module will contain all the validation functions. Currently, I have defined only title validation. I'm making sure that the provided title argument is not null and is at least 3 characters long. If it's not valid it will throw ValueError, it's a part of click API.
1from str_helper import is_null_or_whitespace23def validate_tile(title):4 """Validate blog title.5 - required6 - at least 3 characters7 returns:8 Validation success message.9 """10 if is_null_or_whitespace(title):11 raise ValueError('Blog title is required, min-lenght 3 characters.')1213 if (len(title) < 3):14 raise ValueError('Blog title must be at least 3 characters.')1516 return 'Validation Success: Title "%s" is valid.' % title
Unit testing
For testing I'm using python unit testing framework called unittest. I have covered two modules with unit tests, str_helper, and validator. I'm following the AAA (Arrange, Act, Assert) pattern, it's a common way of writing unit tests.
1- The Arrange section of a unit test method initializes objects and sets the value of the data that is passed to the method under test.23- The Act section invokes the method under test with the arranged parameters.45- The Assert section verifies that the action of the method under test behaves as expected.
1def test_empty_string_is_null_or_whitespace_true(self):2 #Arrange3 text = ''4 #Act5 result = str_helper.is_null_or_whitespace(text)6 #Assert7 self.assertTrue(result)
Summary
In this post I created a subcommand for the CLI tool called "blog", this command takes in an argument called "title" and I added basic validation for argument. Based on validation application print to command line an error or success.
I also covered this logic with a unit tests that I'm running from my editor.
In the next post I'm going to implement a new argument to a "blog" command called image and add image download logic.
Like always, the source code of this post is available in Github.