CLI application tutorial

This tutorial shows how to build a CLI application following the dependency injectionprinciple.

Start from the scratch or jump to the section:

You can find complete project on theGithub.

What are we going to build?

We will build a CLI application that helps to search for the movies. Let’s call it Movie Lister.

How does Movie Lister work?

  • There is a movies database

  • Each movie has next fields:
    • Title

    • Year of the release

    • Director’s name

  • The database is distributed in two formats:
    • Csv

    • Sqlite

  • Application uses the movies database to search for the movies

  • Application can search for the movies by:
    • Director’s name

    • Year of the release

  • Other database formats can be added later

Movie Lister is a naive example from Martin Fowler’s article about the dependency injection andinversion of control:

Here is a class diagram of the Movie Lister application:

../_images/classes-011.png

The responsibilities are split next way:

  • MovieLister - is responsible for the search

  • MovieFinder - is responsible for the fetching from the database

  • Movie - the movie entity

Prepare the environment

Let’s create the environment for the project.

First we need to create a project folder:

mkdirmovie-lister-tutorialcdmovie-lister-tutorial

Now let’s create and activate virtual environment:

python3-mvenvvenv.venv/bin/activate

Project layout

Create next structure in the project root directory. All files are empty. That’s ok for now.

Initial project layout:

./├── movies/│   ├── __init__.py│   ├── __main__.py│   └── containers.py├── venv/├── config.yml└── requirements.txt

Move on to the project requirements.

Install the requirements

Now it’s time to install the project requirements. We will use next packages:

  • dependency-injector - the dependency injection framework

  • pyyaml - the YAML files parsing library, used for the reading of the configuration files

  • pytest - the test framework

  • pytest-cov - the helper library for measuring the test coverage

Put next lines into therequirements.txt file:

dependency-injectorpyyamlpytestpytest-cov

and run next in the terminal:

pipinstall-rrequirements.txt

The requirements are setup. Now we will add the fixtures.

Fixtures

In this section we will add the fixtures.

We will create a script that creates database files.

First add the folderdata/ in the root of the project and then add the filefixtures.py inside of it:

./├── data/│   └── fixtures.py├── movies/│   ├── __init__.py│   ├── __main__.py│   └── containers.py├── venv/├── config.yml└── requirements.txt

Second put next in thefixtures.py:

"""Fixtures module."""importcsvimportsqlite3importpathlibSAMPLE_DATA=[("The Hunger Games: Mockingjay - Part 2",2015,"Francis Lawrence"),("Rogue One: A Star Wars Story",2016,"Gareth Edwards"),("The Jungle Book",2016,"Jon Favreau"),]FILE=pathlib.Path(__file__)DIR=FILE.parentCSV_FILE=DIR/"movies.csv"SQLITE_FILE=DIR/"movies.db"defcreate_csv(movies_data,path):withopen(path,"w")asopened_file:writer=csv.writer(opened_file)forrowinmovies_data:writer.writerow(row)defcreate_sqlite(movies_data,path):withsqlite3.connect(path)asdb:db.execute("CREATE TABLE IF NOT EXISTS movies ""(title text, year int, director text)")db.execute("DELETE FROM movies")db.executemany("INSERT INTO movies VALUES (?,?,?)",movies_data)defmain():create_csv(SAMPLE_DATA,CSV_FILE)create_sqlite(SAMPLE_DATA,SQLITE_FILE)print("OK")if__name__=="__main__":main()

Now run in the terminal:

pythondata/fixtures.py

You should see:

OK

Check that filesmovies.csv andmovies.db have appeared in thedata/ folder:

./├── data/│   ├── fixtures.py│   ├── movies.csv│   └── movies.db├── movies/│   ├── __init__.py│   ├── __main__.py│   └── containers.py├── venv/├── config.yml└── requirements.txt

Fixtures are created. Let’s move on.

Container

In this section we will add the main part of our application - the container.

Container will keep all of the application components and their dependencies.

Editcontainers.py:

"""Containers module."""fromdependency_injectorimportcontainersclassContainer(containers.DeclarativeContainer):...

Container is empty for now. We will add the providers in the following sections.

Let’s also create themain() function. Its responsibility is to run our application. For nowit will just do nothing.

Edit__main__.py:

"""Main module."""from.containersimportContainerdefmain()->None:...if__name__=="__main__":container=Container()main()

Csv finder

In this section we will build everything we need for working with the csv file formats.

We will add:

  • TheMovie entity

  • TheMovieFinder base class

  • TheCsvMovieFinder finder implementation

  • TheMovieLister class

After each step we will add the provider to the container.

../_images/classes-021.png

Create theentities.py in themovies package:

./├── data/│   ├── fixtures.py│   ├── movies.csv│   └── movies.db├── movies/│   ├── __init__.py│   ├── __main__.py│   ├── containers.py│   └── entities.py├── venv/├── config.yml└── requirements.txt

and put next into it:

"""Movie entities module."""classMovie:def__init__(self,title:str,year:int,director:str):self.title=str(title)self.year=int(year)self.director=str(director)def__repr__(self):return"{0}(title={1}, year={2}, director={3})".format(self.__class__.__name__,repr(self.title),repr(self.year),repr(self.director),)

Now we need to add theMovie factory to the container. We need to add import of theproviders module from thedependency_injector package, importentities module.

Editcontainers.py:

"""Containers module."""fromdependency_injectorimportcontainers,providersfrom.importentitiesclassContainer(containers.DeclarativeContainer):movie=providers.Factory(entities.Movie)

Note

Don’t forget to remove the Ellipsis... from the container. We don’t need it anymoresince we container is not empty.

Let’s move on to the finders.

Create thefinders.py in themovies package:

./├── data/│   ├── fixtures.py│   ├── movies.csv│   └── movies.db├── movies/│   ├── __init__.py│   ├── __main__.py│   ├── containers.py│   ├── entities.py│   └── finders.py├── venv/├── config.yml└── requirements.txt

and put next into it:

"""Movie finders module."""importcsvfromtypingimportCallable,Listfrom.entitiesimportMovieclassMovieFinder:def__init__(self,movie_factory:Callable[...,Movie])->None:self._movie_factory=movie_factorydeffind_all(self)->List[Movie]:raiseNotImplementedError()classCsvMovieFinder(MovieFinder):def__init__(self,movie_factory:Callable[...,Movie],path:str,delimiter:str,)->None:self._csv_file_path=pathself._delimiter=delimitersuper().__init__(movie_factory)deffind_all(self)->List[Movie]:withopen(self._csv_file_path)ascsv_file:csv_reader=csv.reader(csv_file,delimiter=self._delimiter)return[self._movie_factory(*row)forrowincsv_reader]

Now let’s add the csv finder into the container.

Editcontainers.py:

"""Containers module."""fromdependency_injectorimportcontainers,providersfrom.importfinders,entitiesclassContainer(containers.DeclarativeContainer):config=providers.Configuration(yaml_files=["config.yml"])movie=providers.Factory(entities.Movie)csv_finder=providers.Singleton(finders.CsvMovieFinder,movie_factory=movie.provider,path=config.finder.csv.path,delimiter=config.finder.csv.delimiter,)

The csv finder needs the movie factory. It needs it to create theMovie entities whenreads the csv rows. To provide the factory we use.provider factory attribute.This is also called the delegation of the provider. If we just pass the movie factoryas the dependency, it will be called when csv finder is created and theMovie instance willbe injected. With the.provider attribute the provider itself will be injected.

The csv finder also has a few dependencies on the configuration options. We added a configurationprovider to provide these dependencies and specified the location of the configuration file.The configuration provider will parse the configuration file when we create a container instance.

Not let’s define the configuration values.

Editconfig.yml:

finder:csv:path:"data/movies.csv"delimiter:","

The configuration file is ready. Move on to the lister.

Create thelisters.py in themovies package:

./├── data/│   ├── fixtures.py│   ├── movies.csv│   └── movies.db├── movies/│   ├── __init__.py│   ├── __main__.py│   ├── containers.py│   ├── entities.py│   ├── finders.py│   └── listers.py├── venv/├── config.yml└── requirements.txt

and put next into it:

"""Movie listers module."""from.findersimportMovieFinderclassMovieLister:def__init__(self,movie_finder:MovieFinder):self._movie_finder=movie_finderdefmovies_directed_by(self,director):return[movieformovieinself._movie_finder.find_all()ifmovie.director==director]defmovies_released_in(self,year):return[movieformovieinself._movie_finder.find_all()ifmovie.year==year]

and editcontainers.py:

"""Containers module."""fromdependency_injectorimportcontainers,providersfrom.importfinders,listers,entitiesclassContainer(containers.DeclarativeContainer):config=providers.Configuration(yaml_files=["config.yml"])movie=providers.Factory(entities.Movie)csv_finder=providers.Singleton(finders.CsvMovieFinder,movie_factory=movie.provider,path=config.finder.csv.path,delimiter=config.finder.csv.delimiter,)lister=providers.Factory(listers.MovieLister,movie_finder=csv_finder,)

All the components are created and added to the container.

Let’s inject thelister into themain() function.

Edit__main__.py:

"""Main module."""fromdependency_injector.wiringimportProvide,injectfrom.listersimportMovieListerfrom.containersimportContainer@injectdefmain(lister:MovieLister=Provide[Container.lister])->None:...if__name__=="__main__":container=Container()container.wire(modules=[__name__])main()

Now when we callmain() the container will assemble and inject the movie lister.

Let’s add some payload tomain() function. It will list movies directed byFrancis Lawrence and movies released in 2016.

Edit__main__.py:

"""Main module."""fromdependency_injector.wiringimportProvide,injectfrom.listersimportMovieListerfrom.containersimportContainer@injectdefmain(lister:MovieLister=Provide[Container.lister])->None:print("Francis Lawrence movies:")formovieinlister.movies_directed_by("Francis Lawrence"):print("\t-",movie)print("2016 movies:")formovieinlister.movies_released_in(2016):print("\t-",movie)if__name__=="__main__":container=Container()container.wire(modules=[__name__])main()

All set. Now we run the application.

Run in the terminal:

python-mmovies

You should see:

Francis Lawrence movies:    - Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')2016 movies:    - Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards')    - Movie(title='The Jungle Book', year=2016, director='Jon Favreau')

Our application can work with the movies database in the csv format. We also want to supportthe sqlite format. We will deal with it in the next section.

Sqlite finder

In this section we will add another type of the finder - the sqlite finder.

Let’s get to work.

Editfinders.py:

"""Movie finders module."""importcsvimportsqlite3fromtypingimportCallable,Listfrom.entitiesimportMovieclassMovieFinder:def__init__(self,movie_factory:Callable[...,Movie])->None:self._movie_factory=movie_factorydeffind_all(self)->List[Movie]:raiseNotImplementedError()classCsvMovieFinder(MovieFinder):def__init__(self,movie_factory:Callable[...,Movie],path:str,delimiter:str,)->None:self._csv_file_path=pathself._delimiter=delimitersuper().__init__(movie_factory)deffind_all(self)->List[Movie]:withopen(self._csv_file_path)ascsv_file:csv_reader=csv.reader(csv_file,delimiter=self._delimiter)return[self._movie_factory(*row)forrowincsv_reader]classSqliteMovieFinder(MovieFinder):def__init__(self,movie_factory:Callable[...,Movie],path:str,)->None:self._database=sqlite3.connect(path)super().__init__(movie_factory)deffind_all(self)->List[Movie]:withself._databaseasdb:rows=db.execute("SELECT title, year, director FROM movies")return[self._movie_factory(*row)forrowinrows]

Now we need to add the sqlite finder to the container and update lister’s dependency to use it.

Editcontainers.py:

"""Containers module."""fromdependency_injectorimportcontainers,providersfrom.importfinders,listers,entitiesclassContainer(containers.DeclarativeContainer):config=providers.Configuration(yaml_files=["config.yml"])movie=providers.Factory(entities.Movie)csv_finder=providers.Singleton(finders.CsvMovieFinder,movie_factory=movie.provider,path=config.finder.csv.path,delimiter=config.finder.csv.delimiter,)sqlite_finder=providers.Singleton(finders.SqliteMovieFinder,movie_factory=movie.provider,path=config.finder.sqlite.path,)lister=providers.Factory(listers.MovieLister,movie_finder=sqlite_finder,)

The sqlite finder has a dependency on the configuration option. Let’s update the configurationfile.

Editconfig.yml:

finder:csv:path:"data/movies.csv"delimiter:","sqlite:path:"data/movies.db"

All is ready. Let’s check.

Run in the terminal:

python-mmovies

You should see:

Francis Lawrence movies:    - Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')2016 movies:    - Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards')    - Movie(title='The Jungle Book', year=2016, director='Jon Favreau')

Our application now supports both formats: csv files and sqlite databases. Every time when weneed to work with the different format we need to make a code change in the container. We willimprove this in the next section.

Selector

In this section we will make our application more flexible.

The code change will not be needed to switch between csv and sqlite formats. We implement theswitch based on the environment variableMOVIE_FINDER_TYPE:

  • WhenMOVIE_FINDER_TYPE=csv application uses csv finder.

  • WhenMOVIE_FINDER_TYPE=sqlite application uses sqlite finder.

We will use theSelector provider. It selects the provider based on the configuration option(docs -Selector provider).

Editcontainers.py:

"""Containers module."""fromdependency_injectorimportcontainers,providersfrom.importfinders,listers,entitiesclassContainer(containers.DeclarativeContainer):config=providers.Configuration(yaml_files=["config.yml"])movie=providers.Factory(entities.Movie)csv_finder=providers.Singleton(finders.CsvMovieFinder,movie_factory=movie.provider,path=config.finder.csv.path,delimiter=config.finder.csv.delimiter,)sqlite_finder=providers.Singleton(finders.SqliteMovieFinder,movie_factory=movie.provider,path=config.finder.sqlite.path,)finder=providers.Selector(config.finder.type,csv=csv_finder,sqlite=sqlite_finder,)lister=providers.Factory(listers.MovieLister,movie_finder=finder,)

The switch is theconfig.finder.type option. When its value iscsv, the provider with thecsv key is used. The same is forsqlite.

Now we need to read the value of theconfig.finder.type option from the environment variableMOVIE_FINDER_TYPE.

Edit__main__.py:

"""Main module."""fromdependency_injector.wiringimportProvide,injectfrom.listersimportMovieListerfrom.containersimportContainer@injectdefmain(lister:MovieLister=Provide[Container.lister])->None:print("Francis Lawrence movies:")formovieinlister.movies_directed_by("Francis Lawrence"):print("\t-",movie)print("2016 movies:")formovieinlister.movies_released_in(2016):print("\t-",movie)if__name__=="__main__":container=Container()container.config.finder.type.from_env("MOVIE_FINDER_TYPE")container.wire(modules=[sys.modules[__name__]])main()

Done.

Run in the terminal line by line:

MOVIE_FINDER_TYPE=csvpython-mmoviesMOVIE_FINDER_TYPE=sqlitepython-mmovies

The output should be similar for each command:

Francis Lawrence movies:    - Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')2016 movies:    - Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards')    - Movie(title='The Jungle Book', year=2016, director='Jon Favreau')

In the next section we will add some tests.

Tests

It would be nice to add some tests. Let’s do it.

We will usepytest andcoverage.

Createtests.py in themovies package:

./├── data/│   ├── fixtures.py│   ├── movies.csv│   └── movies.db├── movies/│   ├── __init__.py│   ├── __main__.py│   ├── containers.py│   ├── entities.py│   ├── finders.py│   ├── listers.py│   └── tests.py├── venv/├── config.yml└── requirements.txt

and put next into it:

"""Tests module."""fromunittestimportmockimportpytestfrom.containersimportContainer@pytest.fixturedefcontainer():container=Container(config={"finder":{"type":"csv","csv":{"path":"/fake-movies.csv","delimiter":",",},"sqlite":{"path":"/fake-movies.db",},},},)returncontainer@pytest.fixturedeffinder_mock(container):finder_mock=mock.Mock()finder_mock.find_all.return_value=[container.movie("The 33",2015,"Patricia Riggen"),container.movie("The Jungle Book",2016,"Jon Favreau"),]returnfinder_mockdeftest_movies_directed_by(container,finder_mock):withcontainer.finder.override(finder_mock):lister=container.lister()movies=lister.movies_directed_by("Jon Favreau")assertlen(movies)==1assertmovies[0].title=="The Jungle Book"deftest_movies_released_in(container,finder_mock):withcontainer.finder.override(finder_mock):lister=container.lister()movies=lister.movies_released_in(2015)assertlen(movies)==1assertmovies[0].title=="The 33"

Run in the terminal:

pytestmovies/tests.py--cov=movies

You should see:

platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.10.0, pluggy-1.0.0plugins: cov-3.0.0collected 2 itemsmovies/tests.py ..                                              [100%]---------- coverage: platform darwin, python 3.10 -----------Name                   Stmts   Miss  Cover------------------------------------------movies/__init__.py         0      0   100%movies/__main__.py        16     16     0%movies/containers.py       9      0   100%movies/entities.py         7      1    86%movies/finders.py         26     13    50%movies/listers.py          8      0   100%movies/tests.py           24      0   100%------------------------------------------TOTAL                     90     30    67%

Note

Take a look at the highlights in thetests.py.

We use.override() method of thefinder provider. Provider is overridden by the mock.Every time when any other provider will requestfinder provider to provide the dependency,the mock will be returned. So when we call thelister provider, theMovieListerinstance is created with the mock, not an actualMovieFinder.

Conclusion

In this tutorial we’ve built a CLI application following the dependency injection principle.We’ve used theDependencyInjector as a dependency injection framework.

With a help ofContainers andProviders we have defined how to assemble application components.

Selector provider served as a switch for selecting the database format based on a configuration.Configuration provider helped to deal with reading a YAML file and environment variables.

We usedWiring feature to inject the dependencies into themain() function.Provider overriding feature helped in testing.

We kept all the dependencies injected explicitly. This will help when you need to add orchange something in future.

You can find complete project on theGithub.

What’s next?

Sponsor the project on GitHub: