Integer range in Flask REST API using SQLAlchemy - rest

I am creating a REST API using Flask, SQLAlchemy, and Marshmallow. I have defined my Product Model in app.py as:
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow
import os
# Initialize App
app = Flask(__name__)
basedir = os.path.abspath(os.path.dirname(__file__))
# Database Setup
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'db.sqlite')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Init db
db = SQLAlchemy(app)
# Init marshmallow
ma = Marshmallow(app)
# Product Class/Model
class Product(db.Model):
id = db.Column(db.Integer, primary_key=True)
price = db.Column(db.Integer)
qty = db.Column(db.Integer)
def __init__(self, price, qty):
self.price = price
self.qty = qty
# Product Schema
class ProductSchema(ma.Schema):
class Meta:
fields = ('id', 'price', 'qty')
# Init Schema
product_schema = ProductSchema()
products_schema = ProductSchema(many=True)
# Create Product
#app.route('/product', methods=['POST'])
def add_product():
price = request.json['price']
qty = request.json['qty']
new_product = Product(price, qty)
db.session.add(new_product)
db.session.commit()
return product_schema.jsonify(new_product)
# Run the Server
if __name__ == '__main__':
app.run(debug=True)
I have to perform the following logic:
Setting price value between 0 - 100
Setting qty value between 0 - 100
If success return 200, if anything wrong return 500.
I am not able to set Integer value between the given range by db.Integer([0, 100]) as its giving me error:
TypeError: Integer() takes no arguments
How do I implement the above logic?

Edit: I've misunderstood the question and made a new function.
def ExpectedRange(var1):
return 200 if var1 in range(0,100) else 500
# Create Product
#app.route('/product', methods=['POST'])
def add_product():
price = request.json['price']
qty = request.json['qty']
if ExpectedRange(price) and ExpectedRange(qty) == 200:
new_product = Product(price, qty)
db.session.add(new_product)
db.session.commit()
return product_schema.jsonify(new_product)
#else:
# Show error. I recommend you using the method 'flash' in flask.
I think the problem with your code by using db.Integer([0, 100]) as a way to find the value between 0 and 100, instead, what you should be doing is by using range with the help of a method called randrange from the library random. With all due respect, I actually don't know what you are trying to accomplish, if I am wrong, please correct me in the comments and I'll correct my post.
What I recommend you doing is to not set the price and qty in the model class, but rather, in an entirely different function and using your model class to create the elements within your database. For example:
from random import randrange
class Product(db.Model):
id = db.Column(db.Integer, primary_key=True)
price = db.Column(db.Integer)
qty = db.Column(db.Integer)
def ProductRange(range1, range2):
return randrange(range1, range2)
print(ProductRange(1,100))
What the function ProductRange will do is to choose the range between the variable range1 and range2. As for returning 200 and 500, I am not sure what you could use with this value, but I recommend doing boolean. If it is needed, 200 and 500 is simply a constant, and you could easily implement it via putting it in a function rather than using the returned value to calculate things. So, how would you use the ProductRange function? Just follow the code below.
from random import randrange
class Product(db.Model):
id = db.Column(db.Integer, primary_key=True)
product_name = db.Column(db.String) # Ignore this line, this just for the /addpost route to get the method POST
price = db.Column(db.Integer)
qty = db.Column(db.Integer)
def ProductRange(range1, range2):
return randrange(range1, range2)
# This route is just an example of how you would use the function ProductRange
#app.route('/addpost', methods=['POST'])
def addpost():
product_name = request.form['product_name']
price = ProductRange(1,100)
qty = ProductRange(1,100)
post = Product[product_name=product_name, price=price, qty=qty]
db.session.add(post)
db.session.commit()
return redirect(url_for('index'))
If it doesn't work, please comment down below for me to help you further with this question of yours. I wish you good luck.

since you have installed marshmallow, install the marmallow-sqlalchemy and use SQLAlchemyAutoSchema feature which will allow to you to refer directly to the model and create an instance after successful load of the json object sent in request body, plus you can define your own constraints in the schema class. the marshmallow conf. will look like:
from marshmallow import ValidationError, fields
from marshmallow.validate import Range
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
ma = Marshmallow(app)
# to return marshmallow parsing error
#app.errorhandler(ValidationError)
def handle_marshmallow_validation(err):
print(err)
return jsonify(err.messages), 400
# Product Schema
class ProductSchema(ma.SQLAlchemyAutoSchema):
id = fields.Integer(required=False)
price = fields.Integer(required=True, validate=[Range(max=100, error="Value must be 100 or less")])
qty = fields.Integer(required=True, validate=[Range(max=100, error="Value must be 100 or less")])
class Meta:
model = Product
load_instance = True
now the ressource will look like:
# Create Product
#app.route('/product', methods=['POST'])
def add_product():
# here we can check the payload validity, parse it and transform it directly to instance
product_json = request.get_json()
new_product = product_schema.load(product_json)
db.session.add(new_product)
db.session.commit()
return product_schema.dump(new_product)
now if you sent value outside the range you will receive response like this
{
"price": [
"Value must be 100 or less"
],
"qty": [
"Value must be 100 or less"
]
}

Related

Error when reloading new Plugin code in QGIS

I am nooby to Plugin Development but I'm trying to create a plugin in QGIS, for a Uni-subject, with its own graphical interface, which will receive a zipcode from the user and return the name of the corresponding location.
I already created the plugin skeleton, through the Plugin Builder and have designed the graphical interface with QtDesigner https://i.stack.imgur.com/h6k6Q.png . I also added the .txt file that contains the zipcodes database to the plugin folder as a resource.
From what I understand, the file to edit is the one that ends in dialog.py, through the init() method, in order to establish the connections between the signals emitted by the elements of the graphical interface and the corresponding callbacks.
However, when I change the code in the dialog.py and reload the plugin, it gives me an error, and when starting the QGIS it pop-ups an error message, and the plugin no longer appears. Error message after plugin reload
Could you give me some guidance here and maybe point me to where the problem could be? Thanks
The code is this one:
import os
import sys
import qgis.core
from qgis.PyQt import uic
from qgis.PyQt import (
QtCore,
QtWidgets
)
import geocoder
sys.path.append(os.path.dirname(__file__))
FORM_CLASS, _ = uic.loadUiType(
os.path.join(
os.path.dirname(__file__),
"example_dialog_base.ui"
),
resource_suffix=""
)
class ExampleDialog(QtWidgets.QDialog, FORM_CLASS):
POSTAL_CODES_PATH = ":/plugins/example/todos_cp.txt"
def __init__(self, parent=None):
"""Constructor."""
super(ExampleDialog, self).__init__(parent)
self.setupUi(self)
# connect signals
self.postal_code_le.textChanged.connect(self.toggle_find_button)
self.find_code_btn.clicked.connect(self.execute)
# set initial state
self.find_code_btn.setEnabled(False)
def toggle_find_button(self):
if self.postal_code_le.text() == "":
self.find_code_btn.setEnabled(False)
else:
self.find_code_btn.setEnabled(True)
def execute(self):
self.address_te.clear()
try:
raw_postal_code = self.postal_code_le.text()
main_code, extension = validate_postal_code(raw_postal_code)
record = self.find_record(main_code, extension)
place_name = record[3]
self.address_te.setPlainText(place_name)
if self.create_layer_chb.isChecked():
self.handle_layer_creation(record)
except (ValueError, RuntimeError) as err:
self.show_error(str(err))
def find_record(self, main_code, extension):
file_handler = QtCore.QFile(self.POSTAL_CODES_PATH)
file_handler.open(QtCore.QIODevice.ReadOnly)
stream = QtCore.QTextStream(file_handler)
while not stream.atEnd():
line = stream.readLine()
info = line.split(";")
code1 = info[-3]
code2 = info[-2]
if code1 == main_code and code2 == extension:
result = info
break
else:
raise RuntimeError("Sem resultados")
return result
def handle_layer_creation(self, record):
place_name = record[3]
point = geocode_place_name(place_name)
print("lon: {} - lat: {}".format(point.x(), point.y()))
layer = create_point_layer(
point,
f"found_location_for_{record[-3]}_{record[-2]}",
place_name
)
current_project = qgis.core.QgsProject.instance()
current_project.addMapLayer(layer)
def show_error(self, message):
message_bar = self.iface.messageBar()
message_bar.pushMessage("Error", message, level=message_bar.Critical)
def validate_postal_code(raw_postal_code):
code1, code2 = raw_postal_code.partition("-")[::2]
if code1 == "" or code2 == "":
raise ValueError(
"Incorrect postal code: {!r}".format(raw_postal_code))
return code1, code2
def geocode_place_name(place_name):
geocoder_object = geocoder.osm(place_name)
lon = geocoder_object.json.get("lng")
lat = geocoder_object.json.get("lat")
if lat is None or lon is None:
raise RuntimeError(
"Could not retrieve lon/lat for "
"place: {!r}".format(place_name)
)
point = qgis.core.QgsPointXY(lon, lat)
return point
def create_point_layer(point, layer_name, place_name):
layer = qgis.core.QgsVectorLayer(
"Point?crs=epsg:4326&field=address:string(100)",
layer_name,
"memory"
)
provider = layer.dataProvider()
geometry = qgis.core.QgsGeometry.fromPointXY(point)
feature = qgis.core.QgsFeature()
feature.setGeometry(geometry)
feature.setAttributes([place_name])
provider.addFeatures([feature])
layer.updateExtents()
return layer

Django ORM instance methods vs property

https://docs.djangoproject.com/en/4.0/topics/db/models/#model-methods
I can't figure out the motivation due to which one might choose using an instance method over property or vice versa. Any ideas ?
from django.db import models
class Person(models.Model):
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
birth_date = models.DateField()
def baby_boomer_status(self):
"Returns the person's baby-boomer status."
import datetime
if self.birth_date < datetime.date(1945, 8, 1):
return "Pre-boomer"
elif self.birth_date < datetime.date(1965, 1, 1):
return "Baby boomer"
else:
return "Post-boomer"
#property
def full_name(self):
"Returns the person's full name."
return '%s %s' % (self.first_name, self.last_name)

Upsert statement with Flask-SQLAlchemy

I have a Flask app that parses a CSV of public election data and inserts the results into a Postgres database. It's a port of an old, not-Flask, Python 2 app that uses various libraries that no longer work. I'm mostly trying to base the application's structure on this tutorial. I've been using Flask-SQLAlchemy to construct some models for the database tables and populate the data from the CSV.
In this case I'm working with an Area model, which corresponds to a geographic area that might have an election (house district, school board district, etc). Here's what I've got in my basic blueprint route:
election = None
#bp.route('/areas')
def scrape_areas():
area = Area()
sources = area.read_sources()
election = area.set_election()
if election not in sources:
return
# Get metadata about election
election_meta = sources[election]['meta'] if 'meta' in sources[election] else {}
for i in sources[election]:
source = sources[election][i]
if 'type' in source and source['type'] == 'areas':
rows = area.parse_election(source, election_meta)
count = 0
for row in rows:
parsed = area.parser(row, i)
area = Area()
area.from_dict(parsed, new=True)
# this shows the generated string of area_id
# which is a UNIQUE key in the database
print(area)
db.session.add(area)
db.session.commit()
count = count + 1
return count
And here's the models.py:
import logging
import os
import json
import re
import csv
import urllib.request
import calendar
import datetime
from flask import current_app
from app import db
LOG = logging.getLogger(__name__)
scraper_sources_inline = None
class ScraperModel(object):
nonpartisan_parties = ['NP', 'WI', 'N P']
def __init__(self, group_type = None):
"""
Constructor
"""
# this is where scraperwiki was creating and connecting to its database
# we do this in the imported sql file instead
self.read_sources()
def read_sources(self):
"""
Read the scraper_sources.json file.
"""
if scraper_sources_inline is not None:
self.sources = json.loads(scraper_sources_inline)
else:
#sources_file = current_app.config['SOURCES_FILE']
sources_file = os.path.join(current_app.root_path, '../scraper_sources.json')
data = open(sources_file)
self.sources = json.load(data)
return self.sources
def set_election(self):
# Get the newest set
newest = 0
for s in self.sources:
newest = int(s) if int(s) > newest else newest
newest_election = str(newest)
election = newest_election
# Usually we just want the newest election but allow for other situations
election = election if election is not None and election != '' else newest_election
return election
def parse_election(self, source, election_meta = {}):
# Ensure we have a valid parser for this type
parser_method = getattr(self, "parser", None)
if callable(parser_method):
# Check if election has base_url
source['url'] = election_meta['base_url'] + source['url'] if 'base_url' in election_meta else source['url']
# Get data from URL
try:
response = urllib.request.urlopen(source['url'])
lines = [l.decode('latin-1') for l in response.readlines()]
rows = csv.reader(lines, delimiter=';')
return rows
except Exception as err:
LOG.exception('[%s] Error when trying to read URL and parse CSV: %s' % (source['type'], source['url']))
raise
def from_dict(self, data, new=False):
for field in data:
setattr(self, field, data[field])
class Area(ScraperModel, db.Model):
__tablename__ = "areas"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
area_id = db.Column(db.String(255), unique=True, nullable=False)
areas_group = db.Column(db.String(255))
county_id = db.Column(db.String(255))
county_name = db.Column(db.String(255))
ward_id = db.Column(db.String(255))
precinct_id = db.Column(db.String(255))
precinct_name = db.Column(db.String(255))
state_senate_id = db.Column(db.String(255))
state_house_id = db.Column(db.String(255))
county_commissioner_id = db.Column(db.String(255))
district_court_id = db.Column(db.String(255))
soil_water_id = db.Column(db.String(255))
school_district_id = db.Column(db.String(255))
school_district_name = db.Column(db.String(255))
mcd_id = db.Column(db.String(255))
precincts = db.Column(db.String(255))
name = db.Column(db.String(255))
updated = db.Column(db.DateTime, default=db.func.current_timestamp(), onupdate=db.func.current_timestamp())
def __repr__(self):
return '<Area {}>'.format(self.area_id)
def parser(self, row, group):
# General data
parsed = {
'area_id': group + '-',
'areas_group': group,
'county_id': None,
'county_name': None,
'ward_id': None,
'precinct_id': None,
'precinct_name': '',
'state_senate_id': None,
'state_house_id': None,
'county_commissioner_id': None,
'district_court_id': None,
'soil_water_id': None,
'school_district_id': None,
'school_district_name': '',
'mcd_id': None,
'precincts': None,
'name': ''
}
if group == 'municipalities':
parsed['area_id'] = parsed['area_id'] + row[0] + '-' + row[2]
parsed['county_id'] = row[0]
parsed['county_name'] = row[1]
parsed['mcd_id'] = "{0:05d}".format(int(row[2])) #enforce 5 digit
parsed['name'] = row[1]
if group == 'counties':
parsed['area_id'] = parsed['area_id'] + row[0]
parsed['county_id'] = row[0]
parsed['county_name'] = row[1]
parsed['precincts'] = row[2]
if group == 'precincts':
parsed['area_id'] = parsed['area_id'] + row[0] + '-' + row[1]
parsed['county_id'] = row[0]
parsed['precinct_id'] = row[1]
parsed['precinct_name'] = row[2]
parsed['state_senate_id'] = row[3]
parsed['state_house_id'] = row[4]
parsed['county_commissioner_id'] = row[5]
parsed['district_court_id'] = row[6]
parsed['soil_water_id'] = row[7]
parsed['mcd_id'] = row[8]
if group == 'school_districts':
parsed['area_id'] = parsed['area_id'] + row[0]
parsed['school_district_id'] = row[0]
parsed['school_district_name'] = row[1]
parsed['county_id'] = row[2]
parsed['county_name'] = row[3]
return parsed
So Areas is an extension of my default model class because it allows me to set up the fields that are specific to a given area based on the CSV.
Where this code fails is a (relatively) rare case in the CSV data where there might be multiple rows that, in the old application, correspond to the same row in the table. That old application had an array (usually with just one item, representing a UNIQUE column in the database) to instruct the code to run an UPDATE on those rows.
It returns an error like this:
sqlalchemy.exc.IntegrityError: (psycopg2.errors.UniqueViolation) duplicate key value violates unique constraint "areas_area_id_key"
DETAIL: Key (area_id)=(counties-01) already exists.
An example of how it runs when I'm just logging my UNIQUE key value from the model instead of inserting it:
<Area precincts-87-0140>
<Area precincts-87-0145>
<Area precincts-87-0150>
<Area precincts-87-0155>
<Area precincts-87-0160>
<Area precincts-87-0165>
<Area school_districts-0001>
<Area school_districts-0001>
<Area school_districts-0001>
<Area school_districts-0002>
<Area school_districts-0004>
<Area school_districts-0006>
<Area school_districts-0012>
<Area school_districts-0013>
<Area school_districts-0014>
So I've been looking at different methods that Flask can use to run an UPSERT statement because I'd need to update all of the fields, and they'd be different based on both which type of area it is, and also in the other models (election contests or results, for example). Most of what I'm finding uses SQLAlchemy rather than Flask-SQLAlchemy.
I found this answer that looked promising. Here's what I added to my model:
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.sql.expression import Insert
Then I modified the ScraperModel class like this:
class ScraperModel(object):
#compiles(Insert)
def compile_upsert(insert_stmt, compiler, **kwargs):
"""
converts every SQL insert to an upsert i.e;
INSERT INTO test (foo, bar) VALUES (1, 'a')
becomes:
INSERT INTO test (foo, bar) VALUES (1, 'a') ON CONFLICT(foo) DO UPDATE SET (bar = EXCLUDED.bar)
(assuming foo is a primary key)
:param insert_stmt: Original insert statement
:param compiler: SQL Compiler
:param kwargs: optional arguments
:return: upsert statement
"""
pk = insert_stmt.table.primary_key
insert = compiler.visit_insert(insert_stmt, **kwargs)
ondup = f'ON CONFLICT ({",".join(c.name for c in pk)}) DO UPDATE SET'
updates = ', '.join(f"{c.name}=EXCLUDED.{c.name}" for c in insert_stmt.table.columns)
upsert = ' '.join((insert, ondup, updates))
return upsert
I'm clearly misunderstanding how the insert_stmt works because of how the query comes out, but here's the error that it generates:
sqlalchemy.exc.ProgrammingError: (psycopg2.errors.SyntaxError) syntax error at or near "ON"
LINE 1: ..., '54', '', CURRENT_TIMESTAMP) RETURNING areas.id ON CONFLIC...
^
[SQL: INSERT INTO areas (area_id, areas_group, county_id, county_name, ward_id, precinct_id, precinct_name, state_senate_id, state_house_id, county_commissioner_id, district_court_id, soil_water_id, school_district_id, school_district_name, mcd_id, precincts, name, updated) VALUES (%(area_id)s, %(areas_group)s, %(county_id)s, %(county_name)s, %(ward_id)s, %(precinct_id)s, %(precinct_name)s, %(state_senate_id)s, %(state_house_id)s, %(county_commissioner_id)s, %(district_court_id)s, %(soil_water_id)s, %(school_district_id)s, %(school_district_name)s, %(mcd_id)s, %(precincts)s, %(name)s, CURRENT_TIMESTAMP) RETURNING areas.id ON CONFLICT (id) DO UPDATE SET id=EXCLUDED.id, area_id=EXCLUDED.area_id, areas_group=EXCLUDED.areas_group, county_id=EXCLUDED.county_id, county_name=EXCLUDED.county_name, ward_id=EXCLUDED.ward_id, precinct_id=EXCLUDED.precinct_id, precinct_name=EXCLUDED.precinct_name, state_senate_id=EXCLUDED.state_senate_id, state_house_id=EXCLUDED.state_house_id, county_commissioner_id=EXCLUDED.county_commissioner_id, district_court_id=EXCLUDED.district_court_id, soil_water_id=EXCLUDED.soil_water_id, school_district_id=EXCLUDED.school_district_id, school_district_name=EXCLUDED.school_district_name, mcd_id=EXCLUDED.mcd_id, precincts=EXCLUDED.precincts, name=EXCLUDED.name, updated=EXCLUDED.updated]
[parameters: {'area_id': 'counties-01', 'areas_group': 'counties', 'county_id': '01', 'county_name': 'Aitkin', 'ward_id': None, 'precinct_id': None, 'precinct_name': '', 'state_senate_id': None, 'state_house_id': None, 'county_commissioner_id': None, 'district_court_id': None, 'soil_water_id': None, 'school_district_id': None, 'school_district_name': '', 'mcd_id': None, 'precincts': '54', 'name': ''}]
(Background on this error at: https://sqlalche.me/e/14/f405)
I'm hoping I didn't paste too much to be helpful there.
I also found this answer that I read as creating its own insert statement instead of compiling the built in one. Here's what I changed. In the blueprint's imports:
from sqlalchemy.dialects.postgresql import insert
And in the blueprint's loop:
for i in sources[election]:
source = sources[election][i]
if 'type' in source and source['type'] == 'areas':
rows = area.parse_election(source, election_meta)
count = 0
for row in rows:
parsed = area.parser(row, i)
area = Area()
area.from_dict(parsed, new=True)
stmt = insert(Area.__table__).values(parsed)
stmt = stmt.on_conflict_do_update(
# Let's use the constraint name which was visible in the original posts error msg
constraint="['area_id']",
# The columns that should be updated on conflict
set_={
parsed
}
)
db.session.execute(stmt)
count = count + 1
return count
It results in a different error:
TypeError: unhashable type: 'dict'
All that to say, I'm currently at a loss. It's clear to me that I need to modify the INSERT statement, but it's not clear to me which route I should take to modify it, how to make sure that it matches on the correct field (which is called area_id and the key is called areas_id_unique), or how to make sure it updates the correct fields when it does find a match.
What I think I'm finding is that none of this would work because I wasn't matching on a primary key, but on a unique key. What I've done is change the unique key area_id to a primary key. Then, I can use the upsert statement from above.
#compiles(Insert)
def compile_upsert(insert_stmt, compiler, **kwargs):
"""
converts every SQL insert to an upsert i.e;
INSERT INTO test (foo, bar) VALUES (1, 'a')
becomes:
INSERT INTO test (foo, bar) VALUES (1, 'a') ON CONFLICT(foo) DO UPDATE SET (bar = EXCLUDED.bar)
(assuming foo is a primary key)
:param insert_stmt: Original insert statement
:param compiler: SQL Compiler
:param kwargs: optional arguments
:return: upsert statement
"""
pk = insert_stmt.table.primary_key
insert = compiler.visit_insert(insert_stmt, **kwargs)
ondup = f'ON CONFLICT ({",".join(c.name for c in pk)}) DO UPDATE SET'
updates = ', '.join(f"{c.name}=EXCLUDED.{c.name}" for c in insert_stmt.table.columns)
upsert = ' '.join((insert, ondup, updates))
return upsert
I had been trying to change the pk = insert_stmt.table.primary_key line to check for the unique key with no success, but it works just like this if I change that field.
Changing the primary key also fixed the other solution I was trying:
group = []
for row in rows:
parsed = area.parser(row, i)
area = Area()
area.from_dict(parsed, new=True)
group.append(area)
insert(db.session, Area, group)
def insert(session, model, rows):
table = model.__table__
stmt = insert(table)
primary_keys = [key.name for key in inspect(table).primary_key]
update_dict = {c.name: c for c in stmt.excluded if not c.primary_key}
if not update_dict:
raise ValueError("insert_or_update resulted in an empty update_dict")
stmt = stmt.on_conflict_do_update(
index_elements=primary_keys,
set_=update_dict
)
So both solutions were (relatively) workable, but only with a primary key instead of a unique key and that just hadn't been clear to me.

How to set goals for Locust?

We have a Locust load/performance test running (in a docker-compose setup).
It now runs on our build-server.
Ideally we would like the build-job to fail if certain requirements are not met.
For example require a certain average response time, or a minimum number of requests within a given timeout.
The expections/requirements must be compared with the aggregated data. So not in the individual (python) test methods.
One option can be to parse the generated reports, but I imagine locust has built-in support for the feature I'm thinking of.
Have a look at locust-plugins, specifically the custom command line options like --check-rps, --check-fail-ratio and --check-avg-response-time
https://github.com/SvenskaSpel/locust-plugins/blob/master/examples/cmd_line_examples.sh
For the purpose of automated KPI validation, you could create a custom plugin using locust’s event hooks and its inner statistics. The overall plugin design is pretty simple:
Register quitting event
Get all statistics and serialize them
Calculate missing metrics (RPS, percentiles, …)
Check provided KPI definition
Validate provided KPI agains actual measurements
The whole KPI plugin code looks like this:
import logging
from enum import Enum
from typing import List
import locust.env
from locust.stats import calculate_response_time_percentile
class Metric(Enum):
EROR_RATE = 'error_rate'
PERCENTILE_90 = 'percentile_90'
RPS = 'rps'
#staticmethod
def has_value(item):
return item in [v.value for v in Metric.__members__.values()]
class KpiPluigin:
def __init__(
self,
env: locust.env.Environment,
kpis: List,
):
self.env = env
self.kpis = kpis
self.errors = []
self._validate_kpis()
events = self.env.events
events.quitting.add_listener(self.quitting) # pyre-ignore
def quitting(self, environment):
serialized_stats = self.serialize_stats(self.env.stats)
updated_stats = self._update_data(serialized_stats)
self._kpi_check(updated_stats)
self._interpret_errors()
def serialize_stats(self, stats):
return [stats.entries[key].serialize() for key in stats.entries.keys() if
not (stats.entries[key].num_requests == 0 and stats.entries[key].num_failures == 0)]
def _update_data(self, stats):
for stat in stats:
stat['error_rate'] = self._calculate_fail_rate(stat)
stat['percentile_90'] = self._calculate_percentile(stat, 0.90)
stat['rps'] = self._calculate_rps(stat)
return stats
def _calculate_rps(self, stat):
rps = stat['num_reqs_per_sec']
num_of_measurements = len(rps)
return sum(rps.values()) / num_of_measurements
def _calculate_fail_rate(self, stat):
num_failures = stat['num_failures']
num_requests = stat["num_requests"]
return (num_failures / num_requests) * 100
def _calculate_percentile(self, stat, percentile):
response_times = stat['response_times']
num_requests = stat['num_requests']
return calculate_response_time_percentile(response_times, num_requests, percentile)
def _kpi_check(self, stats):
if len(stats) == 0:
return
for kpi in self.kpis:
name = list(kpi.keys())[0]
stat = next(stat for stat in stats if stat["name"] == name)
if stat:
kpi_settings = kpi[list(kpi.keys())[0]]
for kpi_setting in kpi_settings:
self._metrics_check(kpi_setting, stat)
def _metrics_check(self, kpi_setting, stat):
(metric, value) = kpi_setting
name = stat["name"]
if metric == Metric.EROR_RATE.value:
error_rate = stat['error_rate']
error_rate <= value or self._log_error(error_rate, kpi_setting, name)
if metric == Metric.PERCENTILE_90.value:
percentile = stat['percentile_90']
percentile <= value or self._log_error(percentile, kpi_setting, name)
if metric == Metric.RPS.value:
rps = stat['rps']
rps >= value or self._log_error(rps, kpi_setting, name)
def _log_error(self, stat_value, kpi_settings, name):
(metric, value) = kpi_settings
self.errors.append(
f"{metric} for '{name}' is {stat_value}, but expected it to be better than {value}") # noqa: E501
def _interpret_errors(self):
if len(self.errors) == 0:
logging.info('All KPIs are good!')
else:
for error in self.errors:
logging.error(f"SLA failed: \n {error}")
self.env.process_exit_code = 1
def _validate_kpis(self):
for kpi in self.kpis:
kpi_keys = list(kpi.keys())
if len(kpi_keys) > 1:
raise Exception("Every dict must contain definition for only one endpoint")
kpi_settings = kpi[kpi_keys[0]]
if len(kpi_settings) == 0:
raise Exception(f"No KPI defined for endpoint {kpi_keys[0]}")
for kpi_setting in kpi_settings:
(metric, value) = kpi_setting
if not isinstance(value, (int, float)):
raise Exception(f"Provide valid value for '{metric}' metric for endpoint {kpi_keys[0]}")
if not Metric.has_value(metric):
raise Exception(f"Metric {metric} not implemented")
Now you have to register KpiPlugin class within your Locust script and define KPI(s) like this:
events.init.add_listener
def on_locust_init(environment, **_kwargs):
KPI_SETTINGS = [{'/store/inventory': [('percentile_90', 50), ('rps', 500), ('error_rate', 0)]}]
KpiPlugin(env=environment, kpis=KPI_SETTINGS)
The above script will make your build fail in case /store/inventory endpoint won't meet one of the defined criteria — 90 percentile is worse than 50ms, RPS is under 500, the error rate is higher than 0%.

Using MultiValueField and MultiWidget in Django Admin Form for a range

First off, I am using Python 3.7 on Windows and Django 2.1.3.
I am attempting to store a range of numbers in a CharField in a django table. I also want the admin site to display two number text boxes side by side to denote the lower value limit and the upper value limit respectively. I have the following files and django code:
car/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _
# Create your models here.
class Car(models.Model):
class Meta:
verbose_name = 'car'
db_table = 'cars'
verbose_name_plural = 'cars'
make = models.CharField(_('manufacturer'), max_length=32)
model = models.CharField(_('model'), max_length=24)
msrp = models.CharField(_('suggested price'), max_length=11)
car/admin.py
from django.contrib import admin
from django.forms import ModelForm, MultiWidget, MultiValueField, NumberInput, IntegerField
from django.core.validators import MinValueValidator, MaxValueValidator, ValidationError
# Register your models here.
from .models import Car
class RangeWidget(MultiWidget):
def __init__(self, *args, **kwargs):
self.widgets = [NumberInput(), NumberInput()]
super().__init__(self.widgets, *args, **kwargs)
def decompress(self, value):
if value:
return value.split('/')
return ['', '']
def value_from_datadict(self, data, files, name):
datalist = [
widget.value_from_datadict(
data, files, '{name}_{i}'.format(name=name, i=i)
) for i, widget in enumerate(self.widgets)
]
try:
v = '/'.join(datalist)
except ValueError:
return ''
else:
return v
class RangeField(MultiValueField):
widget = RangeWidget
def __init__(self, *args, **kwargs):
error_messages = {
'incomplete': 'Enter two numbers',
}
fields = (
IntegerField(
error_messages={
'incomplete': 'Enter a number between 1 and 99999.'
},
validators=[
MinValueValidator(limit_value=1, message='Field cannot be less than 1'),
MaxValueValidator(limit_value=999999, message='Field cannot be greater than 99999')
],
required=True
),
IntegerField(
error_messages={
'incomplete': 'Enter a number between 1 and 99999 that is equal or greater than the lower limit.'
},
validators=[
MinValueValidator(limit_value=1, message='Field cannot be less than 1'),
MaxValueValidator(limit_value=999999, message='Field cannot be greater than 99999')
],
required=True
)
)
super().__init__(
error_messages=error_messages,
fields=fields,
require_all_fields=True,
**kwargs
)
def compress(self, data_list):
return '/'.join(data_list)
class CarChangeForm(ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['msrp'].widget.attrs.update({'class': 'range', 'min': 1, 'max': 99999})
class Meta:
model = Car
fields = ['make', 'model']
exclude = []
widgets = {
'msrp': RangeWidget
}
def clean(self):
cleaned_data = super().clean()
try:
lower, higher = cleaned_data.get('msrp').split('-')
except (AttributeError, KeyError):
raise ValidationError(message='Provide both msrp values')
if lower.isdigit() and higher.isdigit() and int(lower) > int(higher):
self.add_error(
'msrp',
'The lower MSRP cannot be more than higher MSRP.'
)
else:
cleaned_data['msrp_lower'] = lower
cleaned_data['msrp_higher'] = higher
return cleaned_data
class CarAdmin(admin.ModelAdmin):
fields = [
('make', 'model',),
'msrp',
]
form = CarChangeForm
list_display = ('make', 'model', 'msrp')
admin.site.register(Car, CarAdmin)
It seems like everything works alright, except that the validators that I've specified under RangeField.fields do not validate the form and I am allowed to enter negative numbers and leave fields blank. Why isn't django respecting or running the validators that I've added to these fields? The one validation that DOES work is the one I've done in the custome clean() method where I make sure that the lower limit is not greater than the upper limit.
adding msrp = RangeField() to the CarChangeForm solved the problem. Django doesn't automatically initialize a Field for your column. You have to do it yourself in the form.