# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
"""Functions and classes related to querying the eQSL API.
"""
from datetime import datetime
from io import BytesIO
import requests
from .logbook import Logbook
from ._version import __version__
# region Exceptions
[docs]
class eQSLError(Exception): # pylint: disable=invalid-name
"""An error occurred interfacing with eQSL."""
def __init__(self, message="An error occurred interfacing with eQSL"):
super().__init__(message)
# endregion
# region eQSL API Wrapper
[docs]
class eQSLClient: # pylint: disable=invalid-name
"""API wrapper for eQSL.cc. This class holds a user's authentication to\
perform actions on their behalf.
"""
def __init__(
self, username: str, password: str, qth_nickname: str = None, timeout: int = 15
):
"""Create an eQSLClient object.
Args:
username (str): callsign to login with
password (str): password to login with
qth_nickname (str, optional): QTHNickname. Defaults to None.
timeout (int, optional): time to timeout for the entire Client.\
Defaults to 15.
"""
self.callsign = username
self.timeout = timeout
self.base_url = "https://www.eqsl.cc/qslcard/"
session = requests.Session()
session.params = {
k: v
for k, v in {
"username": username,
"password": password,
"QTHNickname": qth_nickname,
}.items()
if v is not None
}
session.headers = {"User-Agent": "pyQSP/" + __version__}
self.session = session
[docs]
def set_timeout(self, timeout: int):
"""Set timeout for the Client to a new value.
Args:
timeout (int): time to timeout in seconds.
"""
self.timeout = timeout
# actual GETs
[docs]
def get_last_upload_date(self) -> datetime:
"""Gets last upload date for the logged in user.
Raises:
eQSLError: An error occurred interfacing with eQSL.
HTTPError: An error occurred while trying to make a connection.
Returns:
datetime.datetime: datetime of last upload for the active user.\
Contains: Year, Month, Date, Hour, Minute (UTC).
"""
with self.session as s:
response = s.get(
self.base_url + "DisplayLastUploadDate.cfm", timeout=self.timeout
)
if response.status_code == requests.codes.ok:
success_txt = "Your last ADIF upload was"
if success_txt in response.text:
time_str = response.text[
response.text.index("(") + 1 : response.text.index(")")
]
return datetime.strptime(time_str, "%d-%b-%Y at %H:%M UTC")
raise eQSLError(response.text)
raise response.raise_for_status()
[docs]
def fetch_inbox(
self,
limit_date_lo: str = None,
limit_date_hi: str = None,
rcvd_since: str = None,
confirmed_only: str = None,
unconfirmed_only: str = None,
archive: str = None,
ham_only: str = None,
) -> Logbook:
"""Fetches INCOMING QSOs, from the user's eQSL Inbox.
Args:
limit_date_lo (str, optional): Earliest QSO date to download\
(oddly, in MM/DD/YYYY format with escape code 2F for slashes),\
optionally append HH:MM otherwise the default is 00:00.\
Defaults to None.
limit_date_hi (str, optional): Latest QSO date to download\
(oddly, in MM/DD/YYYY format with escape code 2F), optionally\
append HH:MM otherwise the default is 23:59 to include the\
entire day.\
Defaults to None.
rcvd_since (str, optional): (YYYYMMDDHHMM) Everything that was\
entered into the database on or after this date/time (Valid\
range 01/01/1900 - 12/31/2078).\
Defaults to None.
confirmed_only (str, optional): Set to any value to signify you\
only want to download Inbox items you HAVE confirmed.\
Defaults to None.
unconfirmed_only (str, optional): Set to any value to signify you\
only want to download Inbox items you have NOT confirmed.\
Defaults to None.
archive (str, optional): 1 for Archived records ONLY; 0 for Inbox\
(non-archived) ONLY; omit this parameter to retrieve ALL\
records in Inbox and Archive.\
Defaults to None.
ham_only (str, optional): anything, filters out all SWL contacts.\
Defaults to None.
Raises:
eQSLError: An error occurred interfacing with eQSL.
HTTPError: An error occurred while trying to make a connection.
Returns:
qspylib.logbook.Logbook: A logbook containing the user's QSOs.
"""
params = {
"LimitDateLo": limit_date_lo,
"LimitDateHi": limit_date_hi,
"RcvdSince": rcvd_since,
"ConfirmedOnly": confirmed_only,
"UnconfirmedOnly": unconfirmed_only,
"Archive": archive,
"HamOnly": ham_only,
}
# filter down to only used params
params = {k: v for k, v in params.items() if v is not None}
with self.session as s:
response = s.get(
self.base_url + "DownloadInBox.cfm", params=params, timeout=self.timeout
)
if response.status_code == requests.codes.ok:
adif_found_txt = "Your ADIF log file has been built"
adif_status = (
response.text.index(adif_found_txt)
if adif_found_txt in response.text
else -1
)
if adif_status < 0:
raise eQSLError("Failed to generate ADIF.")
adif_link_start_idx = response.text.index('<LI><A HREF="..') + 15
adif_link_end_idx = response.text.index('">.ADI file</A>')
adif_link = (
self.base_url + response.text[adif_link_start_idx:adif_link_end_idx]
)
adif_response = requests.get(adif_link, timeout=self.timeout)
if adif_response.status_code == requests.codes.ok:
return Logbook(self.callsign, adif_response.text)
raise response.raise_for_status()
raise response.raise_for_status()
[docs]
def fetch_inbox_qsls(
self,
limit_date_lo: str = None,
limit_date_hi: str = None,
rcvd_since: str = None,
archive: str = None,
ham_only: str = None,
) -> Logbook:
"""Fetches INCOMING QSLs, from the user's eQSL Inbox.
limit_date_lo (str, optional): Earliest QSO date to download\
(oddly, in MM/DD/YYYY format with escape code 2F for slashes),\
optionally append HH:MM otherwise the default is 00:00.\
Defaults to None.
limit_date_hi (str, optional): Latest QSO date to download\
(oddly, in MM/DD/YYYY format with escape code 2F), optionally\
append HH:MM otherwise the default is 23:59 to include the\
entire day.\
Defaults to None.
rcvd_since (str, optional): (YYYYMMDDHHMM) Everything that was\
entered into the database on or after this date/time (Valid\
range 01/01/1900 - 12/31/2078).\
Defaults to None.
archive (str, optional): 1 for Archived records ONLY; 0 for Inbox\
(non-archived) ONLY; omit this parameter to retrieve ALL\
records in Inbox and Archive.\
Defaults to None.
ham_only (str, optional): anything, filters out all SWL contacts.\
Defaults to None.
Raises:
eQSLError: An error occurred interfacing with eQSL.
HTTPError: An error occurred while trying to make a connection.
Returns:
qspylib.logbook.Logbook: A logbook containing the user's QSOs.
"""
return self.fetch_inbox(
limit_date_lo, limit_date_hi, rcvd_since, "Y", None, archive, ham_only
)
[docs]
def fetch_outbox(self):
"""Fetches OUTGOING QSOs, from the user's eQSL Outbox.
Raises:
eQSLError: An error occurred interfacing with eQSL.
HTTPError: An error occurred while trying to make a connection.
Returns:
qspylib.logbook.Logbook: A logbook containing the user's QSOs.
"""
with self.session as s:
response = s.get(self.base_url + "DownloadADIF.cfm", timeout=self.timeout)
if response.status_code == requests.codes.ok:
adif_found_txt = "Your ADIF log file has been built"
adif_status = (
response.text.index(adif_found_txt)
if adif_found_txt in response.text
else -1
)
if adif_status < 0:
raise eQSLError("Failed to generate ADIF.")
adif_link_start_idx = response.text.index('<LI><A HREF="..') + 15
adif_link_end_idx = response.text.index('">.ADI file</A>')
adif_link = (
self.base_url + response.text[adif_link_start_idx:adif_link_end_idx]
)
adif_response = requests.get(adif_link, timeout=self.timeout)
if adif_response.status_code == requests.codes.ok:
return Logbook(self.callsign, adif_response.text)
raise response.raise_for_status()
raise response.raise_for_status()
def _retrieve_graphic(
self,
callsign_from: str,
qso_year: str,
qso_month: str,
qso_day: str,
qso_hour: str,
qso_minute: str,
qso_band: str,
qso_mode: str,
timeout: int = 15,
) -> BytesIO:
"""Retrieve the graphic image for a QSO from eQSL. This is the raw\
interface, as provided by eQSL.cc, with string parameters.
Note:
It is recommended you use the `retrieve_graphic` method instead.
Args:
callsign_from (str): The callsign of the sender of the eQSL
qso_year (str): YYYY OR YY format date of the QSO
qso_month (str): MM format
qso_day (str): DD format
qso_hour (str): HH format (24-hour time)
qso_minute (str): MM format
qso_band (str): 20m, 80M, 70cm, etc. (case insensitive)
qso_mode (str): Must match exactly and should be an ADIF-compatible mode
timeout (int, optional): time to connection timeout. Defaults to 15.
Raises:
eQSLError: An error occurred interfacing with eQSL.
HTTPError: An error occurred while trying to make a connection.
Returns:
BytesIO: A BytesIO binary stream containing the image data. This\
can be further processed with PIL or other image libraries;\
for instance, using PIL, Image.open(return_val) will give a PIL Image.
"""
params = {
"CallsignFrom": callsign_from,
"QSOYear": qso_year,
"QSOMonth": qso_month,
"QSODay": qso_day,
"QSOHour": qso_hour,
"QSOMinute": qso_minute,
"QSOBand": qso_band,
"QSOMode": qso_mode,
}
response = self.session.get(
self.base_url + "GeteQSL.cfm", params=params, timeout=timeout
)
if response.status_code == requests.codes.ok:
try:
url_beg_idx = response.text.index('<img src="') + 10
url_end_idx = response.text.index('" alt="')
img_url = "https://www.eQSL.cc" + response.text[url_beg_idx:url_end_idx]
img_response = self.session.get(img_url, stream=True, timeout=timeout)
return BytesIO(img_response.content)
except ValueError as exc:
raise eQSLError("Failed to retrieve graphic image") from exc
else:
raise response.raise_for_status()
[docs]
def retrieve_graphic(
self,
callsign_from: str,
qso_datetime: datetime,
band: str,
mode: str,
timeout: int = 15,
) -> BytesIO:
"""Retrieve the graphic image for a QSO from eQSL. This is a simplified\
interface that uses a datetime object.
Args:
callsign_from (str): The callsign of the sender of the eQSL
qso_datetime (datetime): Datetime containing the Year, Month, Day,\
Hour, and Minute of the QSO.
band (str): 20m, 80M, 70cm, etc. (case insensitive)
mode (str): Must match exactly and should be an ADIF-compatible mode
timeout (int, optional): Seconds before query times out. Defaults to 15.
Raises:
eQSLError: An error occurred interfacing with eQSL.
HTTPError: An error occurred while trying to make a connection.
Returns:
BytesIO: A BytesIO binary stream containing the image data. This\
can be further processed with PIL or other image libraries;\
for instance, using PIL, Image.open(return_val) will give a PIL Image.
"""
return self._retrieve_graphic(
callsign_from,
qso_datetime.year,
qso_datetime.month,
qso_datetime.day,
qso_datetime.hour,
qso_datetime.minute,
band,
mode,
timeout,
)
# region Static Methods
[docs]
@staticmethod
def verify_eqsl(
callsign_from: str,
callsign_to: str,
qso_band: str,
qso_mode: str = None,
qso_date: str = None,
timeout: int = 15,
):
"""Verify a QSL with eQSL.
Args:
callsign_from (str): Callsign originating QSO (i.e. N5UP)
callsign_to (str): Callsign receiving QSO (i.e. TE5T)
qso_band (str): Band QSO took place on (i.e. 160m)
qso_mode (str, optional): Mode QSO took place with (i.e. SSB).\
Defaults to None.
qso_date (str, optional): Date QSO took place (i.e. 01/31/2000).\
Defaults to None.
timeout (int, optional): Seconds before connection times out.\
Defaults to 15.
Raises:
eQSLError: An error occurred interfacing with eQSL.
HTTPError: An error occurred while trying to make a connection.
Returns:
bool, str: bool of whether the QSO was verified and a str of extra\
information eQSL reports, such as Authenticity Guaranteed status.
"""
url = "https://www.eqsl.cc/qslcard/VerifyQSO.cfm"
params = {
"CallsignFrom": callsign_from,
"CallsignTo": callsign_to,
"QSOBand": qso_band,
"QSOMode": qso_mode,
"QSODate": qso_date,
}
with requests.Session() as s:
response = s.get(
url,
params=params,
headers={"user-agent": "pyQSP/" + __version__},
timeout=timeout,
)
if response.status_code == requests.codes.ok:
raw_result = response.text
if "Result - QSO on file" in raw_result:
return True, raw_result
if "Parameter missing" not in raw_result:
return False, raw_result
raise eQSLError(raw_result)
raise response.raise_for_status()
[docs]
@staticmethod
def get_ag_list(timeout: int = 15):
"""Get a list of Authenticity Guaranteed members.
Args:
timeout (int, optional): Seconds before connection times out. Defaults to 15.
Raises:
HTTPError: An error occurred while trying to make a connection.
Returns:
tuple, str: tuple contains a list of string callsigns, and a str header\
with the date the list was generated
"""
url = "https://www.eqsl.cc/qslcard/DownloadedFiles/AGMemberList.txt"
with requests.Session() as s:
response = s.get(
url, headers={"user-agent": "pyQSP/" + __version__}, timeout=timeout
)
if response.status_code != requests.codes.ok:
raise response.raise_for_status()
result_list = []
result_list += response.text.split("\r\n")
return set(result_list[1:-1]), str(result_list[0])
[docs]
@staticmethod
def get_ag_list_dated(timeout: int = 15):
"""Get a list of Authenticity Guaranteed eQSL members with the date of\
their last upload to eQSL.
Args:
timeout (int, optional): Seconds before connection times out.\
Defaults to 15.
Raises:
HTTPError: An error occurred while trying to make a connection.
Returns:
tuple: First element is a dict with key: callsign and value: date, and\
second is a header of when this list was generated.
"""
url = "https://www.eqsl.cc/qslcard/DownloadedFiles/AGMemberListDated.txt"
with requests.Session() as s:
response = s.get(
url, headers={"user-agent": "pyQSP/" + __version__}, timeout=timeout
)
if response.status_code != requests.codes.ok:
raise response.raise_for_status()
result_list = response.text.split("\r\n")
loc, header = result_list[1:-1], str(result_list[0])
dict_calls = {}
for pair in loc:
call, date = pair.split(", ")
dict_calls[call] = date
return dict_calls, header
[docs]
@staticmethod
def get_full_member_list(timeout: int = 15):
"""Get a list of all members of QRZ.
Args:
timeout (int, optional): Seconds before connection times out.\
Defaults to 15.
Raises:
HTTPError: An error occurred while trying to make a connection.
Returns:
dict: key is the callsign and the value is a tuple of: GridSquare, AG,\
Last Upload
"""
url = "https://www.eqsl.cc/DownloadedFiles/eQSLMemberList.csv"
with requests.Session() as s:
response = s.get(url, timeout=timeout)
if response.status_code != requests.codes.ok:
raise response.raise_for_status()
result_list = response.text.split("\r\n")[1:-1]
dict_calls = {}
for row in result_list:
data = row.split(",")
dict_calls[data[0]] = data[1:]
return dict_calls
[docs]
@staticmethod
def get_users_data(callsign: str):
"""Get a specific user's data from the full member list.
Note:
This is incredibly slow. A better method probably involves doing some\
vectorization, but that would require adding a dependency.
Args:
callsign (str): callsign to get data about
Returns:
tuple: contains: GridSquare, AG, Last Upload
"""
dict_users: dict = eQSLClient.get_full_member_list()
return dict_users.get(callsign)
# endregion
# endregion