Simplify profile settings
This commit is contained in:
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -1,3 +1,10 @@
|
|||||||
{
|
{
|
||||||
"window.title": "openwrtBuild"
|
"window.title": "openwrtBuild",
|
||||||
|
"cSpell.words": [
|
||||||
|
"infile",
|
||||||
|
"isfile",
|
||||||
|
"openwrt",
|
||||||
|
"regen",
|
||||||
|
"sysbackup"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import filedialog
|
|
||||||
from tkinter import ttk
|
|
||||||
import os.path
|
|
||||||
import csv
|
|
||||||
import json
|
|
||||||
import gzip
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
#####################################################################
|
|
||||||
######################## VARIABLES ##################################
|
|
||||||
#####################################################################
|
|
||||||
|
|
||||||
# Enable file caching and some dev output
|
|
||||||
debug=True
|
|
||||||
|
|
||||||
#####################################################################
|
|
||||||
######################## FUNCTIONS ##################################
|
|
||||||
#####################################################################
|
|
||||||
|
|
||||||
def get_toh():
|
|
||||||
"""
|
|
||||||
Retrieves the openwrt.org table of hardware and returns it as a list of dicts
|
|
||||||
"""
|
|
||||||
toh_cache_file = "sources/toh.tsv"
|
|
||||||
if debug is True and os.path.isfile(toh_cache_file):
|
|
||||||
with open(toh_cache_file) as infile:
|
|
||||||
toh = json.load(infile)
|
|
||||||
else:
|
|
||||||
toh_gz_handle = urllib.request.urlopen("https://openwrt.org/_media/toh_dump_tab_separated.gz")
|
|
||||||
toh_handle = gzip.open(toh_gz_handle, mode='rt', encoding='ISO-8859-1')
|
|
||||||
toh_dict = csv.DictReader(toh_handle, delimiter='\t')
|
|
||||||
# Convert the DictReader object to a native list of dicts
|
|
||||||
toh = list(toh_dict)
|
|
||||||
|
|
||||||
# Sanitize it
|
|
||||||
junk = ['', 'nan', 'NULL', '-', '?', '¿', ' ']
|
|
||||||
toh = [d for d in toh if d['target'] not in junk and d['subtarget'] not in junk]
|
|
||||||
|
|
||||||
# Save to cache file
|
|
||||||
with open(toh_cache_file, 'w') as outfile:
|
|
||||||
json.dump(toh, outfile)
|
|
||||||
|
|
||||||
return toh
|
|
||||||
|
|
||||||
#####################################################################
|
|
||||||
######################## CLASSES ####################################
|
|
||||||
#####################################################################
|
|
||||||
|
|
||||||
class DeviceFrame(tk.Frame):
|
|
||||||
"""This class renders the device information frame"""
|
|
||||||
|
|
||||||
def __init__(self, parent, *args, **kwargs):
|
|
||||||
super().__init__(parent, *args, **kwargs)
|
|
||||||
|
|
||||||
# Make a dict to hold our widgets
|
|
||||||
self.widgets = {}
|
|
||||||
|
|
||||||
# Get the Table of Hardware (toh) data frame from openwrt.org
|
|
||||||
self.toh = get_toh()
|
|
||||||
|
|
||||||
# Create device frame and widgets
|
|
||||||
self.device_frame = tk.LabelFrame(self, text="Select Device")
|
|
||||||
self.device_frame.grid(row=0, column=0, sticky=(tk.W + tk.E))
|
|
||||||
self.info_frame = tk.LabelFrame(self, text="Info")
|
|
||||||
self.info_frame.grid(row=1, column=0, sticky=(tk.W + tk.E))
|
|
||||||
self.version_frame = tk.LabelFrame(self, text="Version")
|
|
||||||
self.version_frame.grid(row=2, column=0, sticky=(tk.W + tk.E))
|
|
||||||
|
|
||||||
self.targets_widget(parent)
|
|
||||||
self.subtargets_widget(parent)
|
|
||||||
self.info_widget(parent)
|
|
||||||
self.version_widget(parent)
|
|
||||||
|
|
||||||
# Create some traces to repopulate the subtarget menu and info widget
|
|
||||||
parent.target.trace("w", lambda var_name, var_index, operation: self.subtargets_widget(parent, regen=True))
|
|
||||||
parent.subtarget.trace("w", lambda var_name, var_index, operation: self.info_widget(parent, regen=True))
|
|
||||||
|
|
||||||
# Place the widgets in the device frame
|
|
||||||
self.widgets['Target'].grid(row=0, column=0)
|
|
||||||
self.widgets['Subtarget'].grid(row=1, column=0)
|
|
||||||
self.widgets['Version'].grid(row=0, column=0)
|
|
||||||
self.widgets['Info'].grid(row=0, column=1)
|
|
||||||
|
|
||||||
|
|
||||||
def targets_widget(self, parent):
|
|
||||||
"""A Combobox of targets from the ToH"""
|
|
||||||
|
|
||||||
targets = [k['target'] for k in self.toh]
|
|
||||||
targets = sorted(set(targets))
|
|
||||||
parent.target.set(targets[0])
|
|
||||||
|
|
||||||
self.widgets['Target'] = LabelInput(
|
|
||||||
self.device_frame,
|
|
||||||
label='Target:',
|
|
||||||
input_class=ttk.Combobox,
|
|
||||||
input_var=parent.target,
|
|
||||||
options_list=targets
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def subtargets_widget(self, parent, regen=None):
|
|
||||||
"""A Combobox of subtargets of the current target"""
|
|
||||||
regen = regen or False
|
|
||||||
subtargets = [d['subtarget'] for d in self.toh if d['target'] == parent.target.get()]
|
|
||||||
subtargets = sorted(set(subtargets))
|
|
||||||
parent.subtarget.set(subtargets[0])
|
|
||||||
if regen is True:
|
|
||||||
self.widgets['Subtarget'].input['values'] = subtargets
|
|
||||||
return
|
|
||||||
self.widgets['Subtarget'] = LabelInput(
|
|
||||||
self.device_frame,
|
|
||||||
label='Subtarget:',
|
|
||||||
input_class=ttk.Combobox,
|
|
||||||
input_var=parent.subtarget,
|
|
||||||
options_list=subtargets
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def info_widget(self, parent, regen=None):
|
|
||||||
"""An InfoBox of info about the current subtarget"""
|
|
||||||
regen = regen or False
|
|
||||||
# Add any stat that you wish to display to this list
|
|
||||||
stats = {'devicetype': 'Type', 'brand': 'Brand', 'model': 'Model', 'cpu': 'CPU', 'supportedsincerel': 'First Release',
|
|
||||||
'supportedcurrentrel': 'Current Release', 'packagearchitecture': 'Arch', 'wikideviurl': 'Wiki'}
|
|
||||||
for device in self.toh:
|
|
||||||
if device['target'] == parent.target.get() and device['subtarget'] == parent.subtarget.get():
|
|
||||||
break
|
|
||||||
# Stringify
|
|
||||||
stats_str = '\n'.join('{}: {}'.format(stats[k], v) for k, v in device.items() if k in stats)
|
|
||||||
parent.subtarget_info.set(stats_str)
|
|
||||||
if regen is True:
|
|
||||||
return
|
|
||||||
self.widgets['Info'] = LabelInput(
|
|
||||||
self.device_frame,
|
|
||||||
label='',
|
|
||||||
input_class=ttk.Label,
|
|
||||||
input_var=parent.subtarget_info,
|
|
||||||
textvariable=parent.subtarget_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def version_widget(self, parent):
|
|
||||||
"""A Combobox of version numbers"""
|
|
||||||
# A space-delim'd string of version numbers TODO: make automatic from scraped data (repo tags?)
|
|
||||||
versions = 'snapshot 12.09'
|
|
||||||
|
|
||||||
parent.version.set('snapshot')
|
|
||||||
|
|
||||||
self.widgets['Version'] = LabelInput(
|
|
||||||
self.version_frame,
|
|
||||||
label='',
|
|
||||||
input_class=ttk.Combobox,
|
|
||||||
input_var=parent.version,
|
|
||||||
options_list=versions
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class LabelInput(tk.Frame):
|
|
||||||
"""A widget containing a label and input together."""
|
|
||||||
|
|
||||||
def __init__(self, parent, *args, label='', input_class=ttk.Entry,
|
|
||||||
input_var=None, input_args=None, label_args=None, options_list=None,
|
|
||||||
**kwargs):
|
|
||||||
super().__init__(parent)
|
|
||||||
input_args = input_args or {}
|
|
||||||
label_args = label_args or {}
|
|
||||||
options_list = options_list or []
|
|
||||||
self.variable = input_var
|
|
||||||
|
|
||||||
if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton):
|
|
||||||
input_args["text"] = label
|
|
||||||
input_args["variable"] = input_var
|
|
||||||
else:
|
|
||||||
self.label = ttk.Label(self, text=label, **label_args)
|
|
||||||
self.label.grid(row=0, column=0, sticky=(tk.W + tk.E))
|
|
||||||
input_args["textvariable"] = input_var
|
|
||||||
if input_class in (ttk.OptionMenu, ttk.Combobox):
|
|
||||||
input_args["values"] = options_list
|
|
||||||
|
|
||||||
|
|
||||||
if input_class is ttk.OptionMenu:
|
|
||||||
self.input = input_class(self, self.variable, *options_list)
|
|
||||||
else:
|
|
||||||
self.input = input_class(self, **input_args)
|
|
||||||
|
|
||||||
self.input.grid(row=1, column=0, sticky=(tk.W + tk.E))
|
|
||||||
self.columnconfigure(0, weight=1)
|
|
||||||
|
|
||||||
def grid(self, sticky=(tk.E + tk.W), **kwargs):
|
|
||||||
super().grid(sticky=sticky, **kwargs)
|
|
||||||
|
|
||||||
def get(self):
|
|
||||||
if self.variable:
|
|
||||||
return self.variable.get()
|
|
||||||
elif type(self.input) == tk.Text:
|
|
||||||
return self.input.get('1.0', tk.END)
|
|
||||||
else:
|
|
||||||
return self.input.get()
|
|
||||||
|
|
||||||
def set(self, value, *args, **kwargs):
|
|
||||||
if type(self.variable) == tk.BooleanVar:
|
|
||||||
self.variable.set(bool(value))
|
|
||||||
elif self.variable:
|
|
||||||
self.variable.set(value, *args, **kwargs)
|
|
||||||
elif type(self.input).__name__.endswith('button'):
|
|
||||||
if value:
|
|
||||||
self.input.select()
|
|
||||||
else:
|
|
||||||
self.input.deselect()
|
|
||||||
elif type(self.input) == tk.Text:
|
|
||||||
self.input.delete('1.0', tk.END)
|
|
||||||
self.input.insert('1.0', value)
|
|
||||||
else:
|
|
||||||
self.input.delete(0, tk.END)
|
|
||||||
self.input.insert(0, value)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GUI(tk.Tk):
|
|
||||||
"""Application root window"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
# Set the window properties
|
|
||||||
self.title("openwrt-build")
|
|
||||||
self.geometry("800x600")
|
|
||||||
self.resizable(width=False, height=False)
|
|
||||||
|
|
||||||
ttk.Label(
|
|
||||||
self,
|
|
||||||
text="OpenWRT Image Builder",
|
|
||||||
font=("TkDefaultFont", 16)
|
|
||||||
).grid(row=0)
|
|
||||||
|
|
||||||
# Let's store the global tk vars in this top-level class
|
|
||||||
self.target = tk.StringVar()
|
|
||||||
self.subtarget = tk.StringVar()
|
|
||||||
self.subtarget_info = tk.StringVar()
|
|
||||||
self.version = tk.StringVar()
|
|
||||||
|
|
||||||
# Add each frame class
|
|
||||||
self.device_frame = DeviceFrame(self)
|
|
||||||
self.device_frame.grid(row=1, column=0, sticky=(tk.W + tk.E))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
app = GUI()
|
|
||||||
app.mainloop()
|
|
||||||
|
|
||||||
90
openwrtBuild
90
openwrtBuild
@@ -63,31 +63,38 @@ setDefaults() {
|
|||||||
[[ -z $_filesroot ]] && _filesroot="$_builddir/files/"
|
[[ -z $_filesroot ]] && _filesroot="$_builddir/files/"
|
||||||
|
|
||||||
# Additional packages for all profiles
|
# Additional packages for all profiles
|
||||||
_packages+=("luci" "nano" "htop" "tcpdump" "diffutils" "tar" "iperf")
|
_packages+=("luci" "luci-ssl" "nano" "htop" "tcpdump" "diffutils" "tar" "iperf")
|
||||||
|
|
||||||
# If no profile is specified, use the TP-Link Archer C7 v2 dumb AP
|
# Exit if no profile specified
|
||||||
[[ -z $_profile ]] && _profile="tplink_archer-c7-v2"
|
[[ -z $_profile ]] && echo "You must specify a target profile (device)" && printHelpAndExit 1
|
||||||
|
|
||||||
|
# By default use latest release
|
||||||
|
[[ -z $_version ]] && _version="21.02.1"
|
||||||
|
|
||||||
# Custom profiles
|
# Custom profiles
|
||||||
# TP-Link Archer C7v2 WAP (dumb AP) w/ legacy drivers for better performance
|
# TP-Link Archer C7v2 WAP (dumb AP) w/ legacy drivers for better performance
|
||||||
if [[ "$_profile" == "tplink_archer-c7-v2" ]]; then
|
if [[ "$_profile" == "archer" ]]; then
|
||||||
[[ -z $_version ]] && _version="21.02.0-rc1"
|
_profile="tplink_archer-c7-v2"
|
||||||
_target="ath79/generic"
|
_target="ath79/generic"
|
||||||
_factory_suffix="squashfs-factory.bin"
|
_filesystem="squashfs"
|
||||||
_sysupgrade_suffix="squashfs-sysupgrade.bin"
|
|
||||||
_packages+=("-dnsmasq" \
|
_packages+=("-dnsmasq" \
|
||||||
"-odhcpd" \
|
"-odhcpd" \
|
||||||
"-iptables" \
|
"-iptables" \
|
||||||
"-ath10k-firmware-qca988x-ct" \
|
"-ath10k-firmware-qca988x-ct" \
|
||||||
"-kmod-ath10k-ct" \
|
"ath10k-firmware-qca988x-ct-full-htt")
|
||||||
"ath10k-firmware-qca988x" \
|
# Linksys EA8300 (dumb AP)
|
||||||
"kmod-ath10k")
|
elif [[ "$_profile" == "linksys" ]]; then
|
||||||
|
_profile="linksys_ea8300"
|
||||||
|
_target="ipq40xx/generic"
|
||||||
|
_filesystem="squashfs"
|
||||||
|
_packages+=("-dnsmasq" \
|
||||||
|
"-odhcpd" \
|
||||||
|
"-iptables" \
|
||||||
|
)
|
||||||
# Raspberry Pi 4B router with USB->Ethernet dongle
|
# Raspberry Pi 4B router with USB->Ethernet dongle
|
||||||
elif [[ "$_profile" == "rpi-4" ]]; then
|
elif [[ "$_profile" == "rpi-4" ]]; then
|
||||||
[[ -z $_version ]] && _version="21.02.0-rc1"
|
|
||||||
_target="bcm27xx/bcm2711"
|
_target="bcm27xx/bcm2711"
|
||||||
_factory_suffix="ext4-factory.img"
|
_filesystem="ext4"
|
||||||
_sysupgrade_suffix="ext4-sysupgrade.img"
|
|
||||||
_packages+=("kmod-usb-net-asix-ax88179" \
|
_packages+=("kmod-usb-net-asix-ax88179" \
|
||||||
"kmod-usb-net-rtl8152" \
|
"kmod-usb-net-rtl8152" \
|
||||||
"luci-app-upnp" \
|
"luci-app-upnp" \
|
||||||
@@ -99,11 +106,9 @@ setDefaults() {
|
|||||||
"luci-app-sqm")
|
"luci-app-sqm")
|
||||||
# NanoPi R2S router
|
# NanoPi R2S router
|
||||||
elif [[ "$_profile" == "r2s" ]]; then
|
elif [[ "$_profile" == "r2s" ]]; then
|
||||||
[[ -z $_version ]] && _version="21.02.0-rc1"
|
|
||||||
_profile="friendlyarm_nanopi-r2s"
|
_profile="friendlyarm_nanopi-r2s"
|
||||||
_target="rockchip/armv8"
|
_target="rockchip/armv8"
|
||||||
_factory_suffix="ext4-factory.img"
|
_filesystem="ext4"
|
||||||
_sysupgrade_suffix="ext4-sysupgrade.img"
|
|
||||||
_packages+=("luci-app-upnp" \
|
_packages+=("luci-app-upnp" \
|
||||||
"luci-app-wireguard" \
|
"luci-app-wireguard" \
|
||||||
"luci-app-vpn-policy-routing" \
|
"luci-app-vpn-policy-routing" \
|
||||||
@@ -114,7 +119,29 @@ setDefaults() {
|
|||||||
"luci-app-statistics" \
|
"luci-app-statistics" \
|
||||||
"collectd-mod-sensors" \
|
"collectd-mod-sensors" \
|
||||||
"collectd-mod-thermal" \
|
"collectd-mod-thermal" \
|
||||||
"lm-sensors")
|
"collectd-mod-conntrack" \
|
||||||
|
"smcroute" \
|
||||||
|
"curl" \
|
||||||
|
"ethtool")
|
||||||
|
elif [[ "$_profile" == "r4s" ]]; then
|
||||||
|
_version="snapshot"
|
||||||
|
_profile="friendlyarm_nanopi-r4s"
|
||||||
|
_target="rockchip/armv8"
|
||||||
|
_filesystem="ext4"
|
||||||
|
_packages+=("luci-app-upnp" \
|
||||||
|
"luci-app-wireguard" \
|
||||||
|
"luci-app-vpn-policy-routing" \
|
||||||
|
"-dnsmasq" \
|
||||||
|
"dnsmasq-full" \
|
||||||
|
"luci-app-ddns" \
|
||||||
|
"luci-app-sqm" \
|
||||||
|
"luci-app-statistics" \
|
||||||
|
"collectd-mod-sensors" \
|
||||||
|
"collectd-mod-thermal" \
|
||||||
|
"collectd-mod-conntrack" \
|
||||||
|
"smcroute" \
|
||||||
|
"curl" \
|
||||||
|
"ethtool")
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,18 +220,22 @@ setVars() {
|
|||||||
export _source_dir="${_source_archive%.tar.xz}"
|
export _source_dir="${_source_archive%.tar.xz}"
|
||||||
export _out_bin_dir="$_builddir/bin/$_profile-$_version/"
|
export _out_bin_dir="$_builddir/bin/$_profile-$_version/"
|
||||||
|
|
||||||
|
export _patches_dir="$_builddir/patches/"
|
||||||
|
export _files_dir="$_builddir/files/"
|
||||||
|
|
||||||
if [[ "$_version" == "snapshot" ]]; then
|
if [[ "$_version" == "snapshot" ]]; then
|
||||||
local _out_prefix="$_out_bin_dir/openwrt-${_target//\//-}-$_profile"
|
local _out_prefix="$_out_bin_dir/openwrt-${_target//\//-}-$_profile"
|
||||||
else
|
else
|
||||||
local _out_prefix="$_out_bin_dir/openwrt-$_version-${_target//\//-}-$_profile"
|
local _out_prefix="$_out_bin_dir/openwrt-$_version-${_target//\//-}-$_profile"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export _factory_bin="$_out_prefix-$_factory_suffix"
|
|
||||||
|
export _factory_bin="$_out_prefix-$_filesystem-factory.bin"
|
||||||
export _factory_bin_fname="${_factory_bin##*/}"
|
export _factory_bin_fname="${_factory_bin##*/}"
|
||||||
export _factory_bin_gz="$_factory_bin.gz"
|
export _factory_bin_gz="$_factory_bin.gz"
|
||||||
export _factory_bin_gz_fname="${_factory_bin_gz##*/}"
|
export _factory_bin_gz_fname="${_factory_bin_gz##*/}"
|
||||||
|
|
||||||
export _sysupgrade_bin="$_out_prefix-$_sysupgrade_suffix"
|
export _sysupgrade_bin="$_out_prefix-$_filesystem-sysupgrade.bin"
|
||||||
export _sysupgrade_bin_fname="${_sysupgrade_bin##*/}"
|
export _sysupgrade_bin_fname="${_sysupgrade_bin##*/}"
|
||||||
export _sysupgrade_bin_gz="$_sysupgrade_bin.gz"
|
export _sysupgrade_bin_gz="$_sysupgrade_bin.gz"
|
||||||
export _sysupgrade_bin_gz_fname="${_sysupgrade_bin_gz##*/}"
|
export _sysupgrade_bin_gz_fname="${_sysupgrade_bin_gz##*/}"
|
||||||
@@ -285,6 +316,20 @@ extractImageBuilder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# copyFiles() {
|
||||||
|
|
||||||
|
# debug "${FUNCNAME[0]}"
|
||||||
|
|
||||||
|
# declare -l _this_files_dir="$_files_dir/$_profile"
|
||||||
|
|
||||||
|
# [[ ! -d "$_files_dir" ]] && return
|
||||||
|
|
||||||
|
# $_profile == "r2s"
|
||||||
|
|
||||||
|
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
makeImage() {
|
makeImage() {
|
||||||
|
|
||||||
debug "${FUNCNAME[0]}"
|
debug "${FUNCNAME[0]}"
|
||||||
@@ -344,7 +389,7 @@ flashImage() {
|
|||||||
|
|
||||||
echo "Unmounting target device $_flash_dev partitions"
|
echo "Unmounting target device $_flash_dev partitions"
|
||||||
debug "umount $_flash_dev?*"
|
debug "umount $_flash_dev?*"
|
||||||
sudo umount "$_flash_dev?*"
|
sudo umount "$_flash_dev"?*
|
||||||
|
|
||||||
debug "sudo dd if=\"$_factory_bin\" of=\"$_flash_dev\" bs=2M conv=fsync"
|
debug "sudo dd if=\"$_factory_bin\" of=\"$_flash_dev\" bs=2M conv=fsync"
|
||||||
if sudo dd if="$_factory_bin" of="$_flash_dev" bs=2M conv=fsync; then
|
if sudo dd if="$_factory_bin" of="$_flash_dev" bs=2M conv=fsync; then
|
||||||
@@ -430,11 +475,12 @@ __main() {
|
|||||||
installPrerequisites
|
installPrerequisites
|
||||||
acquireImageBuilder
|
acquireImageBuilder
|
||||||
extractImageBuilder
|
extractImageBuilder
|
||||||
|
#copyFiles
|
||||||
rm -rf "$_ssh_backup_path"
|
rm -rf "$_ssh_backup_path"
|
||||||
[[ -v _ssh_backup_path ]] && sshBackup "$_ssh_backup_path"
|
[[ -v _ssh_backup_path ]] && sshBackup "$_ssh_backup_path"
|
||||||
if makeImage; then
|
if makeImage; then
|
||||||
[[ -n $_ssh_upgrade_path ]] && sshUpgrade
|
[[ -v _ssh_upgrade_path ]] && sshUpgrade
|
||||||
[[ -n $_flash_dev ]] && flashImage
|
[[ -v _flash_dev ]] && flashImage
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
pandas
|
|
||||||
Reference in New Issue
Block a user