source code is available on my GitHub
In this example, the data comes from the Orlen wholesale price archive.
Due to the fact that the way of downloading data from the website has changed, I also had to change the way my program works (the previous version used searching the website in order to find the appropriate values - web scraping). Now it has become necessary to use an API to obtain price data.
To determine what a correct query to the Orlen API should look like, I used the Web tab of the developer mode of the browser.
The program will download data of fuel prices, and display data and a chart according specific years and months.
The project will consist of the main program file: orlen-prices.py and the utils.py file containing a function to download responses from an external API and a function to convert data in json format to the appropriate Pandas data frame.
Downloaded data will be cached in a simple way to limit the number of requests to the API.
First, I will describe the fetch_data() function from the utils.py file responsible for fetching the response from an external server. If the query is successful, the function returns a response in json format. If an exception occurs, the function returns None.
def fetch_data(product_id, current_date):
url = f'https://tool.orlen.pl/api/wholesalefuelprices/'\
f'ByProduct?productId={product_id}'\
f'&from=2000-01-01'\
f'&to={current_date}'
try:
response = requests.get(url)
except requests.RequestException:
return None
else:
return response.json()
The next function from the utils.py file converts the data in json format to a Pandas dataframe. The resulting frame will contain only two columns, the rest of the data is omitted. The effectiveDate column will contain datetime values and will be set as an index (to facilitate searching by date). The value column will be of type int. Instead, the entire frame will be sorted by index.
def json_to_df(data):
df = pd.json_normalize(data)
df = df[['effectiveDate', 'value']]
df['effectiveDate'] = pd.to_datetime(df['effectiveDate'])
df['value'] = df['value'].astype(int)
df.set_index(df['effectiveDate'], inplace=True)
df.sort_index(inplace=True)
return df
The main file contains the MainWindow class responsible for displaying the program window and all widgets, which is initialized by default:
if __name__ == "__main__":
root = tk.Tk()
root.protocol("WM_DELETE_WINDOW", MainWindow._exit)
app = MainWindow(root)
app.mainloop()
The above code creates an instance of the MainWindow class and sets a function that allows the program to close – it’s a static function of the class:
background – defines the background color of the TreeView widget
The MainWindow class will inherit from the Frame class of the tkinter library:
import tkinter as tk
class MainWindow(tk.Frame):
pass
First, I define class constants and variables, e.g.
- background – defines the background color of the TreeView widget
- price_data – a dictionary containing data downloaded from the API
- current_date – variable containing the current date
- months – list of month abbreviations
- products – a dictionary containing product names and their codes
bacground = '#EEEEEE'
price_data = {}
current_date = date.today()
months = calendar.month_abbr
products = {
'Eurosuper95': 41,
'SuperPlus98': 42,
'Bio100': 47,
'Arktyczny2': 44,
'Ekodiesel': 43,
'GrzewczyEkoterm': 46,
'MiejskiSuper' : 45
}
In the init() method, I initialize the values from the parent class, and then run the create_UI() method responsible for displaying the widgets.
def __init__(self, root):
super().__init__(root)
self.create_UI(root)
the createUI() method sets the size of the program window to maximized, the window title is set and then the appropriate widgets are initialized, i.e.
- the show_menu() method sets the menu bar
- the show_product_combobox() method sets the product combobox
- the show year_combobox() method sets the year box
- the show_radiobuttons() method sets the radio buttons to select the month
- the show_chart_button() method sets the chart display button
- the show_msg_label() method sets the status bar label
- the show_data_table() method sets up a widget that displays price data
def create_UI(self, root):
root.state('zoomed')
root.title('Orlen wholesale prices')
self.show_menu(root)
self.show_product_combobox(root)
self.show_year_combobox(root)
self.show_radiobuttons(root)
self.show_chart_button(root)
self.show_msg_label(root)
self.show_data_table(root)
From the menu it will be possible to optionally close the program by selecting File/Exit, i.e.
def show_menu(self, root):
# Menu bar
menu_bar = tk.Menu(root)
root.config(menu=menu_bar)
# File menu
file_menu = tk.Menu(menu_bar, tearoff=0)
file_menu.add_command(label='Exit', command=self._exit)
menu_bar.add_cascade(label='File', menu=file_menu)
The show_product_combobox() method initializes the Combobox widget, which will be responsible for selecting the product for which we want to track price changes. The selection will be passed using the product_sv variable – StringVar object. Before the user selects the appropriate product, a hint is displayed on the widget.
def show_product_combobox(self, root):
self.product_sv = tk.StringVar(value='')
product_cb = ttk.Combobox(root, textvariable=self.product_sv)
product_cb['values'] = [x for x in self.products]
product_cb.set('Product')
product_cb.state = 'readonly'
product_cb.bind('<<ComboboxSelected>>', self.enable_show_chart_btn)
product_cb.grid(row=0, column=0, padx=10, pady=10)
When the user selects the appropriate product, the associated method is run: enable_show_chart_button(), because by default the chart drawing button is inactive. Initially, the condition is checked whether the button is inactive, then whether the product and year have been selected. If all these conditions are met, the state of the button changes to active.
def enable_show_chart_btn(self, sender):
if str(self.show_chart_btn['state']) == 'disabled' and self.product_sv.get() != 'Product' and self.year_sv.get() != 'Year':
self.show_chart_btn['state'] = '!disabled'
The method of selecting the year for which we want to view data looks similar. Selectable year values range from 2004 to the current year, with the current year being the highest in the list.
def show_year_combobox(self, root):
self.year_sv = tk.StringVar(value='')
year_cb = ttk.Combobox(root, textvariable=self.year_sv)
year_cb['values'] = [x for x in range(self.current_date.year, 2004-1, -1)]
year_cb.set('Year')
year_cb.state = 'readonly'
year_cb.bind('<<ComboboxSelected>>', self.enable_show_chart_btn)
year_cb.grid(row=0, column=1, padx=10, pady=10)
The radio fields for selecting a specific month are embedded in a separate frame, and the selected value is passed by the radio_sv variable. Since calendar.month_abbr returns a list where index 1 corresponds to January, and so on, the value at index 0 contains an empty string. I used this to put the default option – All – to display values for all months of a given year. The enumerate() function used in the list is to place the next radio widget in the new column.
def show_radiobuttons(self, root):
# Frame for Radiobuttons
rb_frame = tk.Frame(root)
rb_frame['borderwidth'] = 1
rb_frame['relief']='groove'
rb_frame.grid(row=0, column=2, sticky='w')
# Radiobuttons
self.radio_sv = tk.StringVar(value='All')
for i, month in enumerate(self.months):
if month=="":
month_rb = tk.Radiobutton(
rb_frame,
text='All',
value='All',
variable=self.radio_sv)
else:
month_rb = tk.Radiobutton(
rb_frame,
text=month,
value=month,
variable=self.radio_sv)
month_rb.grid(row=0, column=i)
The button that starts drawing the chart is inactive by default. Only selecting the product and year changes the state of the button to active. Pressing the button triggers the show_chart_btn_clicked() method.
def show_chart_button(self, root):
self.show_chart_btn = ttk.Button(
root,
text='Show Chart',
command=self.show_chart_btn_clicked)
self.show_chart_btn['state'] = 'disabled'
self.show_chart_btn.grid(row=0, column=3, padx=10, sticky='w')
Data is placed in the Treeview object. These data are downloaded for a specific product and date after pressing the button.
def show_data_table(self, root):
# Show Data Frame
data_frame = tk.Frame(root)
data_frame['borderwidth'] = 1
data_frame['relief']='groove'
data_frame.grid(row=1, column=0, columnspan=2, sticky=tk.N)
# Show Data Table
nametofont("TkHeadingFont").configure(weight='bold')
self.table = ttk.Treeview(data_frame,
show='headings')
scrollbar = ttk.Scrollbar(data_frame,
orient='vertical',
command=self.table.yview)
scrollbar.grid(row=0, column=1, sticky=tk.NS)
self.table.configure(yscrollcommand=scrollbar.set)
The program has a status bar showing info about correct loading of data, lack of data for a selected product in a selected period or errors related to e.g. lack of internet connection.
def show_msg_label(self, root):
self.msg = tk.StringVar()
msg_label = tk.Label(root,
relief='groove',
anchor='w',
textvariable=self.msg)
msg_label.grid(row=3, column=0, columnspan=6, sticky='ews')
root.grid_rowconfigure(3, weight=1)
root.grid_columnconfigure(5, weight=1)
Selecting the graph button does the following:
- clears the status bar message
- if the product data has already been downloaded, it does not query the external API
- if the data are not downloaded yet, an attempt is made to download them from the server. When the attempt fails, a download error message is displayed
- the data is filtered based on the date values selected by the user
- the filtered data is passed to the methods: _set_data_table(), which updates the numerical data, and _show_chart(), which displays the chart
def show_chart_btn_clicked(self):
self.msg.set('')
product_id = self.products[self.product_sv.get()]
if product_id in self.price_data:
data = self.price_data[product_id]
else:
data = fetch_data(product_id, self.current_date)
if data is not None:
self.price_data[product_id] = data
else:
self.msg.set('Error fetching data')
return
product_df = json_to_df(data)
year = self.year_sv.get()
month = self.radio_sv.get()
self.table.delete(*self.table.get_children())
try:
if month == 'All':
chart_df = product_df.loc[year]
else:
month_int = datetime.strptime(month, '%b').month
chart_df = product_df.loc[f'{year}-{month_int}']
except:
self.msg.set('No data for selected date')
for widget in self.chart_canvas.winfo_children():
widget.destroy()
else:
self.msg.set('OK')
self._set_data_table(chart_df[::-1])
self._show_chart(root, chart_df, year, month)
Updating data in the Treeview object displaying numerical data on prices:
def _set_data_table(self, df):
for i, row in enumerate(df.iloc):
date, value = row
if i % 2:
self.table.insert(
parent='', index=i, text='',
values=(date.date(), value),
tag='odd')
else:
self.table.insert(
parent='', index=i, text='',
values=(date.date(), value))
Displaying the graph:
def _show_chart(self, root, df, year, month):
# Show Chart Frame
self.chart_canvas = tk.Frame(root)
self.chart_canvas['borderwidth'] = 1
# self.chart_canvas['relief']='groove'
self.chart_canvas.grid(row=1, column=2)
# Chart
date = [str(x.date()) for x in df['effectiveDate']]
price = df['value'].tolist()
fig, ax = plt.subplots()
if month == 'All':
month = ''
ax.set_title(
f'Orlen wholesale prices \
{self.product_sv.get()} [PLN/m3] - {month} {year}')
ax.set_xlabel('Date')
ax.set_ylabel('Price [PLN]')
fig.autofmt_xdate()
ax.grid(True)
ax.xaxis.set_major_locator(plt.MaxNLocator(12))
ax.yaxis.set_major_locator(plt.MaxNLocator(12))
textstr='(c) S.Kwiatkowski'
props = dict(boxstyle='round', alpha=0.5)
ax.plot(date, price, c='#CA3F62')
ax.text(0.8, 0.95, textstr, transform=ax.transAxes, fontsize=8,
verticalalignment='top', bbox=props)
canvas = FigureCanvasTkAgg(fig, master=self.chart_canvas)
canvas.draw()
canvas.get_tk_widget().grid(row=1, column=2, columnspan=2, rowspan=2, sticky='nswe')