Did you ever wish to have a little python program running on your server to help automate tasks? While a lot can be done with cron jobs, bash scripts, and SSH-based tools such as ansible — sometimes having your own program waiting and ready to do your bidding is exactly what you need.
In this article I will show you how to create a simple python agent that runs as a system daemon and can be invoked via both command line and REST API.
Note: this code should be compatible with any version of Python 3. The full source code is available at: https://github.com/vsalvino/agent-tutorial/blob/master/agent.py
First, create a CLI
First I’m going to create a section for common app functionality. In this case, it will just print a phrase.
# ---- CORE FUNCTIONALITY ------------------------------------------------------
import random
def agent_phrase(randomize: bool = False) -> str:
"""
Returns the agent's catch-phrase.
"""
phrases = [
"The name’s Bond. James Bond.",
"Shaken, not stirred.",
"They say you’re judged by the strength of your enemies.",
"Problem solver? More of a problem eliminator.",
]
if randomize:
r_phrase = random.choice(phrases)
return r_phrase
else:
return phrases[0]
Next, I’m going to create a CLI that can invoke my function.
Let’s to stick to the python standard library and use argparse
to make a
simple command line interface.
The python argparse
docs
will give you a good introduction to how that works. I’m going to assume you’ve
read through that. My structure below is similar to the argparse
tutorial and
uses sub-parsers to create nested commands.
# ---- COMMAND LINE INTERFACE --------------------------------------------------
import argparse
def main() -> None:
"""
Entrypoint into the command-line interface.
"""
parser = argparse.ArgumentParser(
description="A python server agent and CLI."
)
# Subparsers for sub-commands.
subparsers = parser.add_subparsers(title="commands", dest="command")
# Phrase sub-command.
phrase_help = "Prints a phrase."
phrase_cli = subparsers.add_parser(
name="phrase",
description=phrase_help,
help=phrase_help,
)
phrase_cli.add_argument(
"--random",
action="store_true",
help="Print a random phrase each time."
)
# ---- Parse and route the sub-commands ------------------------------------
args = parser.parse_args()
# Phrase
if args.command == "phrase":
print(agent_phrase(args.random))
if __name__ == "__main__":
main()
Now if we invoke from the command line, it will print a phrase. Cool!
$ python agent.py phrase --random
Shaken, not stirred.
Create a REST API
Next we will expose our agent_phrase
function over a RESTful web API.
I am going to do this in pure python, so that you can deploy this to any server
without having to worry about environments, pip packages, and the like.
For a more robust setup, you might want to do this using Bottle, Flask, or even
Django.
# ---- WEB API -----------------------------------------------------------------
from http.server import BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
import json
class WebApp(BaseHTTPRequestHandler):
"""
A simple pure-python web app.
"""
def do_GET(self):
# Parse URL.
parsed_url = urlparse(self.path)
route: str = parsed_url.path
query: dict = parse_qs(parsed_url.query) # each entry is a list.
# Response values.
r_code: int = 200
r_type: str = "application/json"
r_content: str = ""
try:
# Phrase.
if route == "/phrase":
# Check the "random" querystring.
random: bool = query.get("random", [""])[0].lower() == "true"
r_content = json.dumps({
"random": random,
"phrase": agent_phrase(random=random)
})
# Fallback, handle 404s.
else:
r_code = 404
r_content = json.dumps({
"error": "No route found matching {0}".format(route)
})
# Send the response.
self.send_response(r_code)
self.send_header("Content-Type", r_type)
self.end_headers()
self.wfile.write(r_content.encode("utf8"))
# Handle server errors.
except Exception as exc:
self.send_error(500, message="Server Error.", explain=str(exc))
The last step remaining is to actually fire up a web server to serve our app.
I am going to stick to pure python once again and use the built-in wsgiref
server, which is intended for development or very basic production purposes.
To do that, I am going to add another sub-command to our CLI, webserver_cli
to invoke the web server.
# ---- COMMAND LINE INTERFACE --------------------------------------------------
import argparse
def main() -> None:
"""
Entrypoint into the command-line interface.
"""
parser = argparse.ArgumentParser(
description="A python server agent and CLI."
)
# Subparsers for sub-commands.
subparsers = parser.add_subparsers(title="commands", dest="command")
# Phrase sub-command.
phrase_help = "Prints a phrase."
phrase_cli = subparsers.add_parser(
name="phrase",
description=phrase_help,
help=phrase_help,
)
phrase_cli.add_argument(
"--random",
action="store_true",
help="Print a random phrase each time."
)
# Webserver sub-command
webserver_help = "Runs the built-in webserver."
webserver_cli = subparsers.add_parser(
name="webserver",
description=webserver_help,
help=webserver_help,
)
# ---- Parse and route the sub-commands ------------------------------------
args = parser.parse_args()
# Phrase
if args.command == "phrase":
print(agent_phrase(args.random))
# Webserver
elif args.command == "webserver":
try:
from http.server import HTTPServer
print("Running web server...")
httpd = HTTPServer(("localhost", 8000), WebApp)
httpd.serve_forever()
except KeyboardInterrupt:
print("Bye.")
if __name__ == "__main__":
main()
Now I can fire up the web server:
$ python agent.py webserver
Running web server...
...and point my browser to http://localhost:8000/phrase
. It’s alive! We can
also use the randomize option by adding a query string:
http://localhost:8000/phrase?random=true
.
Adding SSL
Just because we are using wsgiref
, doesn’t mean we can’t also have security.
wsgiref
is actually perfectly fine for internal-use-only services with
controlled traffic. Even though it is internal only, we would like our API to be
private and encrypted. It’s actually very simple to add an SSL certificate to
the built-in python webserver.
First, we need to issue a self-signed SSL certificate. I will let you peruse the thousands upon thousands of other blog posts that tell you how to do that. Windows users: if you have git installed, you also have OpenSSL!
With OpenSSL:
$ openssl req -x509 -nodes -newkey rsa:2048 -keyout $HOME/key.pem -out $HOME/cert.pem -days 365
On Windows (with git installed):
PS> & "C:\Program Files\Git\usr\bin\openssl.exe" req -x509 -nodes -newkey rsa:2048 -keyout $HOME\key.pem -out $HOME\cert.pem -days 365
Now that we have a private key (key.pem
) and a public certificate
(cert.pem
), we are ready to wire up SSL/TLS support on our webserver with just
a couple extra lines of code. Modify the webserver_cli
and the
if args.command == "webserver"
sub-command as so:
# ---- COMMAND LINE INTERFACE --------------------------------------------------
....
# Webserver sub-command
webserver_help = "Runs the built-in webserver."
webserver_cli = subparsers.add_parser(
name="webserver",
description=web_help,
help=web_help,
)
webserver_cli.add_argument(
"--ssl_cert",
type=str,
default=None,
help="Path to public SSL certificate file.",
)
webserver_cli.add_argument(
"--ssl_key",
type=str,
default=None,
help="Path to private SSL key file.",
)
....
# Webserver
elif args.command == "webserver":
try:
from http.server import HTTPServer
print("Running web server...")
httpd = HTTPServer(("localhost", 8000), WebApp)
# Use SSL if keys were provided.
if args.ssl_key and args.ssl_cert:
import ssl
httpd.socket = ssl.wrap_socket(
httpd.socket,
keyfile=args.ssl_key,
certfile=args.ssl_cert,
server_side=True
)
httpd.serve_forever()
except KeyboardInterrupt:
print("Bye.")
Now I can fire up the web server with SSL:
$ python agent.py webserver --ssl_cert $HOME/cert.pem --ssl_key $HOME/key.pem
Running web server...
...and point my browser to https://localhost:8000/phrase
. Then accept the
self-signed certificate warning in the browser.
Make Your Python Agent Web API into a Daemon
While this might seem like the hard part, it is actually quite simple.
Unfortunately I have not yet done this part on Windows or Mac, so I will show
you how to do this on a Linux server with systemd
(which is basically every
Linux system on earth at this point... but that’s a flame war for another day).
Create a file, myagent.service
with the correct paths to your python
executable (it could be in a virtual environment if so desired) and your
agent.py
file as so:
[Unit]
Description=My python agent
[Service]
ExecStart=/usr/bin/python3 /path/to/agent.py webserver
Restart=on-failure
[Install]
WantedBy=multi-user.target
Now run a couple OS commands (as root or using sudo
) to install the service:
# copy config
$ cp myagent.service /etc/systemd/system/
# reload configs
$ systemctl daemon-reload
# start the service
$ systemctl start myagent
# enable to start on reboot
$ systemctl enable myagent
That’s All, Folks
Now you can explore the possibilities by replacing our simple agent_phrase
function with additional functionality that invokes sub-processes, writes to
files, or anything else you can imagine doing on a server. You could even have
the agents on two servers talk to each other via the REST API!
The full source code is available at: https://github.com/vsalvino/agent-tutorial/blob/master/agent.py