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:

Copy
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

Copy
1assistant/
2|-- README.md
3|-- assistant.py
4|-- install-dev.sh
5|-- logger.py
6|-- setup.py
7|-- str_helper.py
8|-- test_str_helper.py
9|-- test_validator.py
10|-- 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.

Copy
1import click
2import logger
3import 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.

Copy
1class Config(object):
2
3 def __init__(self):
4 self.verbose = False
5
6pass_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.

Copy
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_config
9def 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.

Copy
1@cli.command()
2@click.argument('title')
3@pass_config
4def 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:

Copy
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.

Copy
1from click import echo, style
2
3def info(isVerbose, text):
4 """Log information to the command line if verbose is enabled."""
5 if isVerbose:
6 echo(text)
7
8def error(text):
9 """Log error message to the command line."""
10 echo(style(text, fg='red'))
11
12def 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.

Copy
1def is_null_or_whitespace(str):
2 """Check if string is null or whitespace."""
3 if str and str.strip():
4 return False
5 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.

Copy
1from str_helper import is_null_or_whitespace
2
3def validate_tile(title):
4 """Validate blog title.
5 - required
6 - at least 3 characters
7 returns:
8 Validation success message.
9 """
10 if is_null_or_whitespace(title):
11 raise ValueError('Blog title is required, min-lenght 3 characters.')
12
13 if (len(title) < 3):
14 raise ValueError('Blog title must be at least 3 characters.')
15
16 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.

Copy
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.
2
3- The Act section invokes the method under test with the arranged parameters.
4
5- The Assert section verifies that the action of the method under test behaves as expected.
Copy
1def test_empty_string_is_null_or_whitespace_true(self):
2 #Arrange
3 text = ''
4 #Act
5 result = str_helper.is_null_or_whitespace(text)
6 #Assert
7 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.

Available resources