CLI application tutorial¶
This tutorial shows how to build a CLI application following the dependency injection principle.
Start from the scratch or jump to the section:
You can find complete project on the Github.
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 and inversion of control:
Here is a class diagram of the Movie Lister application:
../_images/classes-011.pngThe responsibilities are split next way:
MovieLister- is responsible for the searchMovieFinder- is responsible for the fetching from the databaseMovie- the movie entity
Prepare the environment¶
Let’s create the environment for the project.
First we need to create a project folder:
mkdirmovie-lister-tutorial
cdmovie-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 frameworkpyyaml- the YAML files parsing library, used for the reading of the configuration filespytest- the test frameworkpytest-cov- the helper library for measuring the test coverage
Put next lines into the requirements.txt file:
dependency-injector pyyaml pytest pytest-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 folder data/ in the root of the project and then add the file
fixtures.py inside of it:
./ ├── data/ │ └── fixtures.py ├── movies/ │ ├── __init__.py │ ├── __main__.py │ └── containers.py ├── venv/ ├── config.yml └── requirements.txt
Second put next in the fixtures.py:
"""Fixtures module.""" importcsv importsqlite3 importpathlib SAMPLE_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.parent CSV_FILE = DIR / "movies.csv" SQLITE_FILE = DIR / "movies.db" defcreate_csv(movies_data, path): with open(path, "w") as opened_file: writer = csv.writer(opened_file) for row in movies_data: writer.writerow(row) defcreate_sqlite(movies_data, path): with sqlite3.connect(path) as db: 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 files movies.csv and movies.db have appeared in the data/ 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.
Edit containers.py:
"""Containers module.""" fromdependency_injectorimport containers classContainer(containers.DeclarativeContainer): ...
Container is empty for now. We will add the providers in the following sections.
Let’s also create the main() function. Its responsibility is to run our application. For now
it will just do nothing.
Edit __main__.py:
"""Main module.""" from.containersimport Container defmain() -> 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:
The
MovieentityThe
MovieFinderbase classThe
CsvMovieFinderfinder implementationThe
MovieListerclass
After each step we will add the provider to the container.
../_images/classes-021.pngCreate the entities.py in the movies 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 the Movie factory to the container. We need to add import of the
providers module from the dependency_injector package, import entities module.
Edit containers.py:
"""Containers module.""" fromdependency_injectorimport containers, providers from.import entities classContainer(containers.DeclarativeContainer): movie = providers.Factory(entities.Movie)
Note
Don’t forget to remove the Ellipsis ... from the container. We don’t need it anymore
since we container is not empty.
Let’s move on to the finders.
Create the finders.py in the movies 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.""" importcsv fromtypingimport Callable, List from.entitiesimport Movie classMovieFinder: def__init__(self, movie_factory: Callable[..., Movie]) -> None: self._movie_factory = movie_factory deffind_all(self) -> List[Movie]: raise NotImplementedError() classCsvMovieFinder(MovieFinder): def__init__( self, movie_factory: Callable[..., Movie], path: str, delimiter: str, ) -> None: self._csv_file_path = path self._delimiter = delimiter super().__init__(movie_factory) deffind_all(self) -> List[Movie]: with open(self._csv_file_path) as csv_file: csv_reader = csv.reader(csv_file, delimiter=self._delimiter) return [self._movie_factory(*row) for row in csv_reader]
Now let’s add the csv finder into the container.
Edit containers.py:
"""Containers module.""" fromdependency_injectorimport containers, providers from.import finders, entities classContainer(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 the Movie entities when
reads 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 factory
as the dependency, it will be called when csv finder is created and the Movie instance will
be 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 configuration provider 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.
Edit config.yml:
finder: csv: path:"data/movies.csv" delimiter:","
The configuration file is ready. Move on to the lister.
Create the listers.py in the movies 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.findersimport MovieFinder classMovieLister: def__init__(self, movie_finder: MovieFinder): self._movie_finder = movie_finder defmovies_directed_by(self, director): return [ movie for movie in self._movie_finder.find_all() if movie.director == director ] defmovies_released_in(self, year): return [ movie for movie in self._movie_finder.find_all() if movie.year == year ]
and edit containers.py:
"""Containers module.""" fromdependency_injectorimport containers, providers from.import finders, listers, entities classContainer(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 the lister into the main() function.
Edit __main__.py:
"""Main module.""" fromdependency_injector.wiringimport Provide, inject from.listersimport MovieLister from.containersimport Container @inject defmain(lister: MovieLister = Provide[Container.lister]) -> None: ... if __name__ == "__main__": container = Container() container.wire(modules=[__name__]) main()
Now when we call main() the container will assemble and inject the movie lister.
Let’s add some payload to main() function. It will list movies directed by
Francis Lawrence and movies released in 2016.
Edit __main__.py:
"""Main module.""" fromdependency_injector.wiringimport Provide, inject from.listersimport MovieLister from.containersimport Container @inject defmain(lister: MovieLister = Provide[Container.lister]) -> None: print("Francis Lawrence movies:") for movie in lister.movies_directed_by("Francis Lawrence"): print("\t-", movie) print("2016 movies:") for movie in lister.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 support the 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.
Edit finders.py:
"""Movie finders module.""" importcsv importsqlite3 fromtypingimport Callable, List from.entitiesimport Movie classMovieFinder: def__init__(self, movie_factory: Callable[..., Movie]) -> None: self._movie_factory = movie_factory deffind_all(self) -> List[Movie]: raise NotImplementedError() classCsvMovieFinder(MovieFinder): def__init__( self, movie_factory: Callable[..., Movie], path: str, delimiter: str, ) -> None: self._csv_file_path = path self._delimiter = delimiter super().__init__(movie_factory) deffind_all(self) -> List[Movie]: with open(self._csv_file_path) as csv_file: csv_reader = csv.reader(csv_file, delimiter=self._delimiter) return [self._movie_factory(*row) for row in csv_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]: with self._database as db: rows = db.execute("SELECT title, year, director FROM movies") return [self._movie_factory(*row) for row in rows]
Now we need to add the sqlite finder to the container and update lister’s dependency to use it.
Edit containers.py:
"""Containers module.""" fromdependency_injectorimport containers, providers from.import finders, listers, entities classContainer(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 configuration file.
Edit config.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 we need to work with the different format we need to make a code change in the container. We will improve 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 the
switch based on the environment variable MOVIE_FINDER_TYPE:
When
MOVIE_FINDER_TYPE=csvapplication uses csv finder.When
MOVIE_FINDER_TYPE=sqliteapplication uses sqlite finder.
We will use the Selector provider. It selects the provider based on the configuration option
(docs - Selector provider).
Edit containers.py:
"""Containers module.""" fromdependency_injectorimport containers, providers from.import finders, listers, entities classContainer(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 the config.finder.type option. When its value is csv, the provider with the
csv key is used. The same is for sqlite.
Now we need to read the value of the config.finder.type option from the environment variable
MOVIE_FINDER_TYPE.
Edit __main__.py:
"""Main module.""" fromdependency_injector.wiringimport Provide, inject from.listersimport MovieLister from.containersimport Container @inject defmain(lister: MovieLister = Provide[Container.lister]) -> None: print("Francis Lawrence movies:") for movie in lister.movies_directed_by("Francis Lawrence"): print("\t-", movie) print("2016 movies:") for movie in lister.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-mmovies MOVIE_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 use pytest and coverage.
Create tests.py in the movies 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.""" fromunittestimport mock importpytest from.containersimport Container @pytest.fixture defcontainer(): container = Container( config={ "finder": { "type": "csv", "csv": { "path": "/fake-movies.csv", "delimiter": ",", }, "sqlite": { "path": "/fake-movies.db", }, }, }, ) return container @pytest.fixture deffinder_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"), ] return finder_mock deftest_movies_directed_by(container, finder_mock): with container.finder.override(finder_mock): lister = container.lister() movies = lister.movies_directed_by("Jon Favreau") assert len(movies) == 1 assert movies[0].title == "The Jungle Book" deftest_movies_released_in(container, finder_mock): with container.finder.override(finder_mock): lister = container.lister() movies = lister.movies_released_in(2015) assert len(movies) == 1 assert movies[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.0 plugins: cov-3.0.0 collected 2 items movies/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 the tests.py.
We use .override() method of the finder provider. Provider is overridden by the mock.
Every time when any other provider will request finder provider to provide the dependency,
the mock will be returned. So when we call the lister provider, the MovieLister
instance is created with the mock, not an actual MovieFinder.
Conclusion¶
In this tutorial we’ve built a CLI application following the dependency injection principle.
We’ve used the Dependency Injector as a dependency injection framework.
With a help of Containers and Providers 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 used Wiring feature to inject the dependencies into the main() function.
Provider overriding feature helped in testing.
We kept all the dependencies injected explicitly. This will help when you need to add or change something in future.
You can find complete project on the Github.
What’s next?
Sponsor the project on GitHub:
[フレーム]