DEV Community

Cover image for Developing for the web with Flask; creating a web application.
Billy
Billy

Posted on

Developing for the web with Flask; creating a web application.

Hey there, In this tutorial i will be showing you how to develop your first fullstack web-app project with flask-python.
In this tutorial or rather a documentation of how i did it, buckle up dev, and may the ride begin!

Prerequisites

  • Basic python language skills.
  • Basic command line commands.(linux)
  • PIP( a package manager for python) Installation allows you to install required libraries for a specific project, i.e: Flask
  • IDE(vscode, atom, sublime)
  • Python installed.

Contents

  1. Launch
  2. Installation
  3. Templating
  4. Conditionals
  5. Looping
  6. Web Forms
  7. Database
  8. Viewing Posts
  9. Summary

Launch

Now setup up your project structure.
launch your code editor and create a directory in it.
mkdir blogger && cd blogger

  • Now create your starting files: mkdir app && cd app touch app.py routes.py models.py
  • While in the app directory create two directories mkdir templates static
  • Navigate to the root directory blogger and create the following files touch blog.py config.py all is set and your project structure should be looking like this:-
blogger/
|-app/
     |-templates #html files directory
     |-static #css& images directory
     |-__init__.py
     |-routes.py #site navigation capability
     |-models.py #db logic goes here
|-blog.py # main apprunning module
|-config.py

#tip: identation shows file location, eg; templates is in app dir
Enter fullscreen mode Exit fullscreen mode

When working with external libraries it is good to run your project in a virtual environment, go ahead and install virtualenv via the terminal

pip install virtualenv
#create a virtual environment 
virtualenv bloggerenv
# you can name your env any name
# activating 
# windows
cd bloggerenv/Scripts/activate
# linux
source bloggerenv/bin/activate

#deactivating the env
deactivate 
Enter fullscreen mode Exit fullscreen mode

Installing libraries

We use the PIP to install the required libraries

(bloggerenv) $pip install Flask 
# this are the underlying packages to start a project
Enter fullscreen mode Exit fullscreen mode

open the init.py in app directory type this:

#__init__.py
from flask import Flask
app = Flask(__name__) #flask object instance

from app import routes
Enter fullscreen mode Exit fullscreen mode
  • first we import the Flask class from the flask module.
  • next we create the app object as an instance of the Flask class.
  • then import the routes module, to be created later

Open the routes.py file and we create the first route and test if everything is working.

from app import app

@app.route('/')
@app.route('/index')
def index():
  return "Hello, World!"
Enter fullscreen mode Exit fullscreen mode

The first line imports the application instance.
This is a view function, what it does it returns the string Hello World, to the browser when the app is run.
The two first lines @app.route are called decorators, they create urls for the web-app.

Head over to the blog.py file and create the app running logic.

#blog.py
from app import app

if __name__ == '__main__:
    app.run()
Enter fullscreen mode Exit fullscreen mode

With this our app is done and can be previewed in the browser.But before running Flask needs to be told how to import it, through the FLASK_APP environment.

(bloggerenv) $ export FLASK_APP=blog.py
Enter fullscreen mode Exit fullscreen mode

If you are using a windows OS use the keyword set instead of export.
You can run your app in the terminal now:

(bloggerenv) $ flask run
 * Serving Flask app 'blog.py' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
Enter fullscreen mode Exit fullscreen mode

Copy the url http://127.0.0.1:5000/ in the browser.
App preview
Voila! app running and good.

Templating

In this section i'll show how to work with templates to generate more elaborate web pages with more complex structures.
We are going to use Jinja2, it is a templating engine for
flask.
We are going to store the template/html files in the templates folder.
Create an index.html file and open it.

app/
    |-templates/ 
               |-index.html
Enter fullscreen mode Exit fullscreen mode

I want the homepage to display a custom welcome message to the user
In the routes file create a python dictionary that holds our mock user:

from flask import render_template
from app import app

@app.route('/')
@app.route('/index')
def index():
    user = {'username' : 'Developer'}
    return render_template('index.html', title='Home', user=user)
Enter fullscreen mode Exit fullscreen mode

Here we import the render_template function from the flask module. It handles html rendering in python
Then we create mock data as a python dictionary for a user, this will change henceforth as we include a database.

Let's code the index.html file:-

<!doctype html>
<html>
    <head>
        <title>{{ title }} - blogger</title>
    </head>
    <body>
        <h1>Hello, {{ user.username }}!</h1>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

When we run this version of the app, we get a nice preview with the username printed out.
Jinja2substitutes {{ ... }} blocks with corresponding values, given by the arguments provided in the render_template() call.

App in browser preview

Conditional statements in jinja2

Jinja2 supports control statements given inside {%..%}
Add a conditional statement to the index.html that will print the documents title when provided in the render_template() call and prints out a default title if None is provided.
Put this block in the <head> section:

{% if title %}
  <title>{{ title }}</title>
{% else %}
  <title>App | blogger</title>
{% endif %}
Enter fullscreen mode Exit fullscreen mode

Loops

And yes jinja2 also supports looping, what did you expect!😂
lets add a mock data in our routes.py file for posts

from flask import render_template
from app import app

@app.route('/')
@app.route('/index')
def index():
    user = {'username' : 'Developer'}
    posts =[
        {
        'author': {'username': 'Guido'},
        'body': 'I designed python language'
        },
        {
         'author': {'username': 'Jack'},
        'body': 'Blue is a cool color'
        }
    ]
    return render_template('index.html', title='Homer', user=user, posts=posts)
Enter fullscreen mode Exit fullscreen mode
  • I create a list posts with nested dictionaries, where each element is a dictionary that has author & body fields.

Now head on to index.html and handle the rendering of posts in the browser, posts can be of any number and we need to tell the temlate how to render them all, for that case we use a for loop.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ title }} - blogger</title> 
</head>
<body>
    {% if user.username %}
        <p>Hello, {{ user.username }}</p>
    {% else %}
        <p>Hello, World!</p>
    {% endif %}

    <h2>My posts!</h2>
    {% for post in posts %}
        <b>{{ post.author.username }}</b>
        <p>{{ post.body }}</p>
    {% endfor %}
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Give this new version a try and voila!!🤯🤯
app posts preview

When the structure of the app gets bigger and bigger we need to separate some preferences, for example, when linking css files or having a navbar in our website, its needed anyway.😊 we ought to do it in a separate template file.
We call this template inheritance:-

  • Go ahead and create; base.html in the templates directory.
<!-- base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
    {% block title %} 
        <title>Document</title>
    {% endblock %}
</head>
<body>
    {% block content %}

    {% endblock %}
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

With this we can now simplify our index.html file:-
to inherit a template file we use the {% extends %} tag, at the top of the child template.

{% extends 'base.html' %}
{% block title %}
    {% if user.username %}
        <title>{{ title }}- blogger</title>
    {% else %}
        <p>Blogger</p>
    {% endif %}
{% endblock %}
{% block content %}
    <h2>My posts!</h2>
    {% for post in posts %}
        <b>{{ post.author.username }}</b>
        <p>{{ post.body }}</p>
    {% endfor %}
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Template inheritance allows us to have the same look in every page without duplication.
Hence enabling us to be 'DRY'.
If you go and run this the results will be the same as above.

As the application growsn it will require more functionality and features; like web forms, etc.

Web forms.

In this chapter I'll show you how to create web forms to take user input via the browser.
We use an extension called Flask-WTF, it is a wrapper found in the package WTForms, that integrates well with Flask.

(bloggerenv) $ pip install flask-wtf
Enter fullscreen mode Exit fullscreen mode

Configurations

For tutorial purposes I am going to create just a simple form.
But before that, I am going to set some configuration variables, for separation of concerns i am going to define my app configs in the config.py module in the top most directory.
I'll use a class to store any config variables and new configuration variables can be added here.

# config.py
import os

class Config(object):
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
Enter fullscreen mode Exit fullscreen mode

the SECRET_KEY configuration variable is an important config in most flask apps, it is used by the Flask-WTF to protect web forms against the Cross Site Request Forgery(CSRF) attack.
The value of the secret key is set as an expression with two terms, joined by the or operator. The first term looks for the value of an environment variable, also called SECRET_KEY. The second term, is just a hardcoded string.
For now the app is small and doesn't require much security, i'll just use the hardcoded string, but when deploying it needs to be replaced with a unique key, can be generated in the python shell using the urandom module

>>>from os import urandom
>>>urandom(20)
b'\x1d\x00\x08\x8b \xd8\xae\xe9....
Enter fullscreen mode Exit fullscreen mode

Now that i have the config set up, i have to tell Flask to read and apply it. This can be done right after the flask app instance in the __init__.py using the app.config.from_object() method:.

from flask import Flask
from config import Config

app = Flask(__name__)
app.config.from_object(Config)

from app import routes
Enter fullscreen mode Exit fullscreen mode

Creating the first form, once again having the separation of concerns in mind, in the app directory create a file forms.py
The Flask-WTF extension uses Python classes to represent web forms. A form class simply defines the fields of the form as class variables.

#app/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired

class PostForm(FlaskForm):
    username = StringField('User name', validators=[DataRequired()])
    post = TextAreaField('Post', validators=[DataRequired()])
    submit = SubmitField('Submit')
Enter fullscreen mode Exit fullscreen mode
  • Import the FlaskForm base class.
  • For each form you will need you create a separate class variable, each field is given a label as the first argument.
  • The validators field is used to attach validation behaviors to form fields. -The DataRequired validator checks that a field is not submitted empty.

Form Templates

Next is to add the form to a HTML template, so it gets rendered to the web page.
The LoginForm class knows how to render itself as HTML, this makes the next part fairly simple.

  • create a file new_post.html in app/template/new_post.html N/B:For uniformity inherit the base.html template inside the new template.
{% extends 'base.html' %}
{% block title %}
    {% if title %}
        <title>{{ title }}- blogger</title>
    {% else %}
        <p>Blogger</p>
    {% endif %}
{% endblock %}
{% block content %}
    <form action="" method="POST" novalidate>
        {{ form.hidden_tag() }}
        <p>
            {{ form.username.label }} <br>
            {{ form.username(size=32) }}
        </p>
        <p>
            {{ form.post.label }} <br>
            {{ form.post(size=120) }}
        </p>
        <p>
            {{ form.submit() }}
        </p>
    </form>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

This template expects a form object be instantiated in the PostForm class to be given as an argument.Which is referenced as form, the argument will be sent by the post view function.

  • the html <form> element is used as the container for the web form.
  • the action attribute tells the browser the url to use when the form data is submitted. when set to an empty string the form is submitted to the current URL in the browser.
  • The method attribute specifies the HTTP request method to be used when submitting the form to the server.
  • novalidate tells the browser not to validate the forms as that is the work of Flask application running in the server.
  • The form.hidden_tag() template argument generates a hidden field that includes a token that is used to protect the form against CSRF attacks.
  • The {{ form.<field_name>.label }} replaces the label element, and the {{ form.<field_name>() }} goes where the form field is needed. Read more on the jinja2 documentation.

Form view function.

In order to render the Form to the web page a route functionality is needed, In the routes.py module add the following view function at the end.

#....

# route to the post form
@app.route('/post')
def post():
    form = PostForm()
    return render_template('new_post.html', title='Add post', form=form)
Enter fullscreen mode Exit fullscreen mode
  • Here i imported the PostForm class from forms.py, instantiated an object form = PostForm() from it and sent the object down to the template form=form.
  • The form=form parses the form object to the template form, it is what's required to render the form fields.

When you run the app and navigate to http://127.0.0.1:5000/post a html form is displayed. But when you try to submit it a method not allowed is thrown. I'll fix that in a minute.

app form fields jinja2

Next I'm going to create the navigation links inside the base.html file.

<!-- .... -->
<body>
    <div>
        <b>Blogger:</b>
        <a href="{{ url_for('index') }}">Home</a>
        <a href="{{ url_for('post') }}">Add Post</a>
    </div>
    <hr>
<!-- .... -->
Enter fullscreen mode Exit fullscreen mode

Add the above block just below the body tag, when the app is run we get a simple navbar at the top. That actually works 😉.

  • As you noticed when i created the navbar links i used a different method, the url_for() function from Flask. which generates URLs using iternal mapping of urls to view functions.Read ahead and practice working with forms.

App navbar preview

Remember when you tried running the app and an error came shouting, that was because the post view function didn't understand what to do.
To fix it we add the methods=['POST', 'GET'] in the route decorator.
The new updated route should look like this

# route to the post form
from flask import Flask, redirect, flash
@app.route('/post', methods=['POST', 'GET'])
def post():
    form = PostForm()
    if form.validate_on_submit():
        print(f"Hey, {form.username.data}! Your post '{form.post.data}'  was successfully submitted! ")
        return redirect('/index')
    return render_template('new_post.html', title='Add post', form=form)
Enter fullscreen mode Exit fullscreen mode
  • Here we use an if statement to validate the form data in the view function.
  • The validated_on_submit() runs validation of a form, Call validate only if the form is submitted. This is a shortcut for form.is_submitted() and form.validate().
  • Here I use the print() function to print a custom message in the terminal, later I will be using the Flask flash() function to flash messages in the web page.
  • redirect: this function redirects to a specified url.
  • flash: it flashes a message to the web page after an action has been carried.

Database.

Flask doesn't support databases natively, it isn't opinionated in this field, so it gives you the freedom to choose across a variety of database options that best fits your application.

Databases can be separated into two, those that follow relational models and those that do not. referred as NoSQL dbs.

We are going to use Flask-sqlalchemy, an extension that provides a Flask-friendly wrapper to the popular SQLAlchemy package, SQLAlchemy is an ORMObject Relational Mapper.
An ORM allows applications to manage databases using high level entities such as classes, objects & methods instead of tables and SQL. The work of an ORM is to convert high-level operations into database commands.
Installing flask-sqlalchemy run this in the terminal.
(blogger) $ pip install flask-sqlalchemy

Database Migrations

As the application continues to grow, it might need changes or updates, also the database needs be updated. This is done through migrations and the flask extension that handles this is : Flask-migrate.
This extension is a Flask wrapper for Alembic a flask database migration framework for SQLAlchemy. Install the extension;
(blogger) $ pip install flask-migrate

Flask-SQLalchemy configuration

In the development phase I will be using SQLite database. It saves each database file on disk.
When the app reaches production, I will use a database server: PostgreSQL or MySQL.
Two configurations are required in the config.py module:

import os
basedir = os.path.abspath(os.path.dirname(__file__))

class Config(object):
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///' + os.path.join(basedir, 'app.db')
    SQLALCHEMY_TRACK_MODIFICATIONS = False
Enter fullscreen mode Exit fullscreen mode

The Flask extension gets the location of the apps database from the SQLALCHEMY_DATABASE_URI configuration variable.

  • As I did with the SECRET_KEY variable fallback, I also provide a fallback value if the config variable doesn't define the database url.
  • The SQLALCHEMY_TRACK_MODIFICATIONS configuration if set to False disables a Flask-SQLAlchemy feature that sends a signal to the application every time a change is about to be made to the database.

The database is represented in the application by a database instance. This is done in the app/__init__.py file.

from flask import Flask
from config import Config
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

app = Flask(__name__)
app.config.from_object(Config) #configuration 
db = SQLAlchemy(app) #database instance
migrate = Migrate(app, db)

from app import routes, models

Enter fullscreen mode Exit fullscreen mode
  • In this change of the init file, I imported two new extensions: SQLAlchemy & flask_migrate.
  • I have added a db object, it represents the database.
  • The migrate variable handles the migration engine.
  • At the bottom I've imported a new module models , This module defines the structure of the database.

Database models.

Data stored in the database will be represented by a collection of classes, usually known as database models. The ORM layer will handle the translastions required for mapping objects created from this classes to the correct rows in the database tables.
Let's create a database model, create a new file app/models.py

from app import db

class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(32), index=True, unique=True)
    post = db.Column(db.String(120), index=True)

    def __repr__(self):
        return(f"<User>: {self.username}, <Post>: {self.post}")
Enter fullscreen mode Exit fullscreen mode
  • The id field is usually in all models, and is used as the primary key. Each user is assigned a new id and is stored in this field.
  • The username & post field are defined as strings(in database is known as VARCHAR) and their maximum lengths specified.
  • The Post class created above inherits from db.Model, a base class for all models from Flask-SQLAlchemy.
  • Fields are created as instances of the db.Column class, whih takes field type as arguments.
  • The __repr__ tells python how to print objects of this class. It is useful for debugging. Open python in a terminal try to assign some data to our class objects.
(blogger) $ python
>>> from app import db
>>> from app.models import Post
>>> p = Post(username="John", post="Hello, World!")
>>> p
<User>: John, <Post>: Hello, World!
>>>
Enter fullscreen mode Exit fullscreen mode

The model created above defines the initial database structure or schema of this application.

Create a migration repository.

When the app continues to grow, we need to handle the changes such as adding new things, modifying or removing items.

  • The Alembic extension takes care of this schema changes. It maintains the migration repository, a directory in which it stores the migration scripts.
  • Each a change is made to the database schema, a migration script is added to the repository with the details of the change.
  • To apply the migrations to a database, these migration scripts are executed in the sequence they were created.

  • The flask db sub-command is added by flask-migrate to manage everything related to database migrations.

  • To create a migration repository :

(blogger) $ flask db init
Creating directory E:\code\projects\blogger\migrations ...  done
Creating directory E:\code\projects\blogger\migrations\versions ...  done
Generating E:\code\projects\blogger\migrations\alembic.ini ...  done
Generating E:\code\projects\blogger\migrations\env.py ...  done
Generating E:\code\projects\blogger\migrations\README ...  done
Generating E:\code\projects\blogger\migrations\script.py.mako ...  done
Please edit configuration/connection/logging settings in 'E:\\code\\projects\\blogger\\migrations\\alembic.ini' before proceeding.
Enter fullscreen mode Exit fullscreen mode

The flask command relies on the FLASK_APP environment variable, after a successful run a new migrations directory is added.
Now we need to create our fist database migration.

(blogger) $ flask db migrate -m 'posts table'
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'post'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_post_post' on '['post']'
INFO  [alembic.autogenerate.compare] Detected added index 'ix_post_username' on '['username']'
Generating E:\code\projects\blogger\migrations\versions\549b927398fe_posts_table.py ...  done
Enter fullscreen mode Exit fullscreen mode

After running this, no change has been made to the database it just generates a migration script. Now we need to make changes to the database, To do that we use a flask sub-command flask db upgrade in the python shell context.

$ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade  -> 549b927398fe, posts table
Enter fullscreen mode Exit fullscreen mode

At this point you can play with the database a little, Let's try to add the previous post to the database.

  • The db changes are done in a database session context.
  • To add data to the db we use db.session.add(p), p is the object created for the corresponding database table.
  • To write the changes use: db.session.commit().
(blogger) $ python
>>> from app import db
>>> from app.models import Post
>>> p = Post(username="John", post="Hello, World!")
>>> p
<User>: John, <Post>: Hello, World!
>>> db.session.add(p)
>>> db.session.commit()
>>> Post.query.all()
[<User>: Ariana, <Post>: Testing 2, <User>: John, <Post>: Hello, World!]
>>>

Enter fullscreen mode Exit fullscreen mode
  • first import the database object from the app module
  • Import the Post class from the models module.
  • Create a Post object name it p.
  • use the db session context to add and push the change to the db.
  • The last command Post.query.all() answers queries and returns all posts in the database.

Adding data to the database

When a user types their data in the forms, and hits submit button the form data needs to be written in the database.
To do that I shall add the db session commands in the view function /post.
Your new changed app/routes.py should look like this.

# route to the post form
@app.route('/post', methods=['POST', 'GET'])
def post():
    form = PostForm()
    if form.validate_on_submit():
        add = Post(username=form.username.data, post=form.post.data)
        db.session.add(add)
        db.session.commit() 
        flash(f"Hey, {form.username.data}! Your post '{form.post.data}'  was successfully submitted! ")
        return redirect('/index') 
    return render_template('new_post.html', title='Add post', form=form)

Enter fullscreen mode Exit fullscreen mode

Now when a user enters data in the web forms and hits submit the data gets written to the database, we can view the data in a browser.

Viewing Posts in the Web-page

This part is rather simple, I just need to create a template that renders the posts in the database I will just use a for loop to loop through the available posts in the database.

Head over to the IDE and create a new template file, name it whatever you prefer. app/templates/all_posts.html

{% extends 'base.html' %}
{% block title %}
    {% if title %}
        <title>{{ title }}- blogger</title>
    {% else %}
    <p>Blogger</p>
        {% endif %}
{% endblock %}

{% block content %} 
    <h2>Published Posts.</h2>
    {% for post in posts %}
        <b>{{ post.username }}</b>
        <p>{{ post.post }}</p>
    {% endfor %}
{% endblock %}

Enter fullscreen mode Exit fullscreen mode

When you run the application and navigate to http://127.0.0.1:5000/view all our posts are displayed in the web-page.

app preview

Now we can head and add a nav link for viewing all our posts, this is will be added in the base.html file:

<a href="{{ url_for('view') }}">Posts</a>
Enter fullscreen mode Exit fullscreen mode

Add this line of code in the div inside the base template file. Preview the app in the browser and a new a navbar link has been added.

Summary

In this article I took you through working with flask to create a minimal web application that takes user input via a web form and saves it in a database and retrieves it and displays it in a web-page.

  • I worked you on using the Flask-SQLAlchemy and integrating a flask app with a database.
  • Creating a database and managing the database modifications.
  • Templating and rendering web-pages. Go ahead and play with the UI by changing the style.css inside the app/static directory
body {
    background-color: #7fffd4;
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

When I add a the background color property the web page color changed, I shall write more on this in another article.

Thank you for taking your time to go through this article, Any feedback, advice or help is appreciated. Drop me a comment for any issue with running the code or improvement suggestions ;-)

In the next article i'll cover on deploying this flask app to heroku.
References: [https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world]

The whole code is available on Github, go ahead and star the repo or clone it for your practice.

Thank you @grayhat and Lux Tech Academy.

Be cool and Keep coding! mic drop...dev out

Discussion (6)

Collapse
corentinbettiol profile image
Corentin Bettiol

Nice tutorial :)

However I would use python3 -m pip instead of pip and venv instead of virtualenv.

Some other comments are summarized in this txt file: misc.l3m.in/txt/python.txt

Collapse
billyndirangu profile image
Billy Author

thank you,
I will adopt this in my next project.
what difference does python3 -m pip have from using pip directly?

Collapse
corentinbettiol profile image
Corentin Bettiol

There's soooo much to tell :D

Adam Johnson has made a good list here: adamj.eu/tech/2020/02/25/use-pytho...

Thread Thread
billyndirangu profile image
Billy Author

Thank you, i get it now.
we keep coding

Collapse
vajracool profile image
VajraCool

This is an awesome tutorial , thank you!

Collapse
billyndirangu profile image
Billy Author

Thank you for reading it.