diff --git a/CHANGELOG.md b/CHANGELOG.md
index 582f1170..2ca038a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
# Changelog
+- [25-1-1] Added ARRL RTTY RU.
- [24-12-29] Add {LOGIT} macro.
- [24-12-15] Fixed Button focus policy in the bandmap window.
- [24-12-14] Changed method of detecting fldigi QSOs. See docs.
@@ -11,7 +12,7 @@
- [24-12-8-1] Changed cabrillo names for Weekly RTTY, CW Ops CWT and K1USN SST.
- [24-12-8] Fix: Weekly RTTY mults. Add RTC to Weekly RTTY.
- [24-12-6] Add RTC to K1USN.
--[24-12-5-1] ARRL 160 gets rtc.
+- [24-12-5-1] ARRL 160 gets rtc.
- [24-12-5] Add 'real time' score posting to external sites.
- [24-12-4] Merged PR from @alduhoo Add STATION_CALLSIGN field to ADIF output
- [24-12-3-1] Adding ARRL 160
diff --git a/README.md b/README.md
index f648a330..65af68b0 100644
--- a/README.md
+++ b/README.md
@@ -183,6 +183,7 @@ generated, 'cause I'm lazy, list of those who've submitted PR's.
- ARRL 160M
- ARRL DX CW, SSB
- ARRL Field Day
+- ARRL RTTY Roundup
- ARRL Sweepstakes CW, SSB
- ARRL VHF January, June, September
- CQ 160 CW, SSB
@@ -208,22 +209,7 @@ generated, 'cause I'm lazy, list of those who've submitted PR's.
## Recent Changes (Polishing the Turd)
-- [24-12-29] Add {LOGIT} macro.
-- [24-12-15] Fixed Button focus policy in the bandmap window.
-- [24-12-14] Changed method of detecting fldigi QSOs. See docs.
-- [24-12-12] Add a try exception for a unicode decode error.
-- [24-12-11-1] Add RTC to RAC Canada Day, ARRL VHF, ARRL Field Day, ARRL SS, ARRL DX, 10 10
-- [24-12-11] Add RTC to IARU HF, IARU Field Day, DARC XMAS, CQ WW, CQ WPX
-- [24-12-9] Add RTC to Winter Field Day, Stew Perry, REF, RAEM, NAQP, LZ-DX, JIDX
-- [24-12-8-2] Add RTC to ARRL 10M, Tweaked cabrillo file output.
-- [24-12-8-1] Changed cabrillo names for Weekly RTTY, CW Ops CWT and K1USN SST.
-- [24-12-8] Fix: Weekly RTTY mults. Add RTC to Weekly RTTY.
-- [24-12-6] Add RTC to K1USN.
--[24-12-5-1] ARRL 160 gets rtc.
-- [24-12-5] Add 'real time' score posting to external sites.
-- [24-12-4] Merged PR from @alduhoo Add STATION_CALLSIGN field to ADIF output
-- [24-12-3-1] Adding ARRL 160
-- [24-12-3] Add button to bandmap to delete marked spots.
+- [25-1-1] Added ARRL RTTY RU.
See [CHANGELOG.md](CHANGELOG.md) for prior changes.
diff --git a/not1mm/data/new_contest.ui b/not1mm/data/new_contest.ui
index 28ff222a..4c71c0db 100644
--- a/not1mm/data/new_contest.ui
+++ b/not1mm/data/new_contest.ui
@@ -237,6 +237,11 @@
ARRL FIELD DAY
+ -
+
+ ARRL RTTY RU
+
+
-
ARRL SS CW
diff --git a/not1mm/lib/version.py b/not1mm/lib/version.py
index c735bd26..787dd525 100644
--- a/not1mm/lib/version.py
+++ b/not1mm/lib/version.py
@@ -1,3 +1,3 @@
"""It's the version"""
-__version__ = "24.12.29"
+__version__ = "25.1.1"
diff --git a/not1mm/plugins/arrl_rtty_ru.py b/not1mm/plugins/arrl_rtty_ru.py
index 6db61b2a..6955127e 100644
--- a/not1mm/plugins/arrl_rtty_ru.py
+++ b/not1mm/plugins/arrl_rtty_ru.py
@@ -1,19 +1,55 @@
-"""ARRL plugin"""
+"""
+Mode: RTTY
+ Bands: 80, 40, 20, 15, 10m
+ Classes: Single Op (QRP/Low/High)
+Single Op Unlimited (QRP/Low/High)
+Single Op Overlay: (Limited Antennas)
+Multi-Single (Low/High)
+Multi-Two
+Multi-Multi
+ Max operating hours: 24 hours
+ Max power: HP: 1500 watts
+LP: 100 watts
+ Exchange: W/VE: RST + (state/province)
+non-W/VE: RST + Serial No.
+ Work stations: Once per band
+ QSO Points: 1 point per QSO
+ Multipliers: Each US state+DC (except KH6/KL7) once only
+Each VE province/territory once only
+Each DXCC country (including KH6/KL7) once only
+ Score Calculation: Total score = total QSO points x total mults
+ Submit logs by: 2359Z January 12, 2025
+ E-mail logs to: (none)
+ Upload log at: https://contest-log-submission.arrl.org/
+ Mail logs to: RTTY Roundup
+ARRL
+225 Main St.
+Newington, CT 06111
+USA
+ Find rules at: https://www.arrl.org/rtty-roundup
+
+
+"""
# pylint: disable=invalid-name, unused-argument, unused-variable, c-extension-no-member
import datetime
-from decimal import Decimal
-from pathlib import Path
+import logging
+from pathlib import Path
from PyQt6 import QtWidgets
-from not1mm.lib.plugin_common import online_score_xml
-EXCHANGE_HINT = ""
+from not1mm.lib.plugin_common import gen_adif, get_points, online_score_xml
+from not1mm.lib.version import __version__
+
+logger = logging.getLogger(__name__)
-name = "ARRL RTTY Round Up"
+EXCHANGE_HINT = "State/Province or #"
+
+name = "ARRL RTTY Roundup"
+mode = "RTTY" # CW SSB BOTH RTTY
cabrillo_name = "ARRL-RTTY"
-mode = "BOTH" # CW SSB BOTH RTTY
+
columns = [
"YYYY-MM-DD HH:MM:SS",
"Call",
@@ -22,23 +58,13 @@
"Rcv",
"SentNr",
"RcvNr",
- "Exchange1",
- "CK",
- "Prec",
- "Sect",
- "WPX",
- "Power",
- "M1",
- "ZN",
- "M2",
- "PFX",
"PTS",
- "Name",
- "Comment",
]
+advance_on_space = [True, True, True, True, True]
+
# 1 once per contest, 2 work each band, 3 each band/mode, 4 no dupe checking
-dupe_type = 4
+dupe_type = 2
def init_contest(self):
@@ -46,10 +72,23 @@ def init_contest(self):
set_tab_next(self)
set_tab_prev(self)
interface(self)
+ self.next_field = self.other_2
def interface(self):
"""Setup user interface"""
+ self.field1.show()
+ self.field2.show()
+ self.field3.show()
+ self.field4.show()
+ self.snt_label.setText("SNT")
+ self.field1.setAccessibleName("RST Sent")
+ # label = self.field3.findChild(QtWidgets.QLabel)
+ self.other_label.setText("SentNR")
+ self.field3.setAccessibleName("Sent Number")
+ # label = self.field4.findChild(QtWidgets.QLabel)
+ self.exch_label.setText("State|Prov|SN")
+ self.field4.setAccessibleName("State Province or Serial Number")
def reset_label(self):
@@ -78,8 +117,22 @@ def set_tab_prev(self):
}
+def validate(self):
+ """doc"""
+ # exchange = self.other_2.text().upper().split()
+ # if len(exchange) == 3:
+ # if exchange[0].isalpha() and exchange[1].isdigit() and exchange[2].isalpha():
+ # return True
+ # return False
+ return True
+
+
def set_contact_vars(self):
"""Contest Specific"""
+ self.contact["SNT"] = self.sent.text()
+ self.contact["RCV"] = self.receive.text()
+ self.contact["NR"] = self.other_2.text().upper()
+ self.contact["SentNr"] = self.other_1.text()
def predupe(self):
@@ -87,15 +140,51 @@ def predupe(self):
def prefill(self):
- """Fill SentNR"""
+ """Fill sentnr"""
+ result = self.database.get_serial()
+ serial_nr = str(result.get("serial_nr", "1")).zfill(3)
+ if serial_nr == "None":
+ serial_nr = "001"
+
+ exchange = self.contest_settings.get("SentExchange", "").replace("#", serial_nr)
+ if len(self.other_1.text()) == 0:
+ self.other_1.setText(exchange)
def points(self):
"""Calc point"""
+ return 1
def show_mults(self, rtc=None):
"""Return display string for mults"""
+ # CountryPrefix, integer
+
+ us_ve = 0
+ dx = 0
+
+ sql = (
+ "select count(DISTINCT(NR || ':' || Mode)) as mult_count "
+ f"from dxlog where ContestNR = {self.database.current_contest} and typeof(NR) = 'text';"
+ )
+ result = self.database.exec_sql(sql)
+ if result:
+ us_ve = result.get("mult_count", 0)
+
+ # DX
+ sql = (
+ "select count(DISTINCT(CountryPrefix || ':' || Mode)) as mult_count "
+ f"from dxlog where ContestNR = {self.database.current_contest} "
+ "and typeof(NR) = 'integer';"
+ )
+ result = self.database.exec_sql(sql)
+ if result:
+ dx = result.get("mult_count", 0)
+
+ if rtc is not None:
+ return dx, us_ve
+
+ return us_ve + dx
def show_qso(self):
@@ -106,183 +195,346 @@ def show_qso(self):
return 0
-def get_points(self):
- """Return raw points before mults"""
- result = self.database.fetch_points()
- if result:
- return int(result.get("Points", 0))
- return 0
-
-
def calc_score(self):
"""Return calculated score"""
- result = self.database.fetch_points()
+ # Multipliers: Each US State + DC once per mode
+ _points = get_points(self)
+ _mults = show_mults(self)
+ _power_mult = 1
+ # if self.contest_settings.get("PowerCategory", "") == "QRP":
+ # _power_mult = 2
+ return _points * _power_mult * _mults
def adif(self):
- """
- Creates an ADIF file of the contacts made.
- """
+ """Call the generate ADIF function"""
+ gen_adif(self, cabrillo_name)
+
+
+def output_cabrillo_line(line_to_output, ending, file_descriptor, file_encoding):
+ """"""
+ print(
+ line_to_output.encode(file_encoding, errors="ignore").decode(),
+ end=ending,
+ file=file_descriptor,
+ )
+
+
+def cabrillo(self, file_encoding):
+ """Generates Cabrillo file. Maybe."""
+ # https://www.cqwpx.com/cabrillo.htm
+ logger.debug("******Cabrillo*****")
+ logger.debug("Station: %s", f"{self.station}")
+ logger.debug("Contest: %s", f"{self.contest_settings}")
now = datetime.datetime.now()
date_time = now.strftime("%Y-%m-%d_%H-%M-%S")
filename = (
str(Path.home())
+ "/"
- + f"{self.station.get('Call').upper()}_{cabrillo_name}_{date_time}.adi"
+ + f"{self.station.get('Call', '').upper()}_{cabrillo_name}_{date_time}.log"
)
+ logger.debug("%s", filename)
log = self.database.fetch_all_contacts_asc()
try:
- with open(filename, "w", encoding="utf-8") as file_descriptor:
- print("2.2.0", end="\r\n", file=file_descriptor)
- print("", end="\r\n", file=file_descriptor)
+ with open(filename, "w", encoding=file_encoding) as file_descriptor:
+ output_cabrillo_line(
+ "START-OF-LOG: 3.0",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"CREATED-BY: Not1MM v{__version__}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"CONTEST: {cabrillo_name}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ if self.station.get("Club", ""):
+ output_cabrillo_line(
+ f"CLUB: {self.station.get('Club', '')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"CALLSIGN: {self.station.get('Call','')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"LOCATION: {self.station.get('ARRLSection', '')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"CATEGORY-OPERATOR: {self.contest_settings.get('OperatorCategory','')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"CATEGORY-ASSISTED: {self.contest_settings.get('AssistedCategory','')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"CATEGORY-BAND: {self.contest_settings.get('BandCategory','')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ mode = self.contest_settings.get("ModeCategory", "")
+ if mode in ["SSB+CW", "SSB+CW+DIGITAL"]:
+ mode = "MIXED"
+ output_cabrillo_line(
+ f"CATEGORY-MODE: {mode}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"CATEGORY-TRANSMITTER: {self.contest_settings.get('TransmitterCategory','')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ if self.contest_settings.get("OverlayCategory", "") != "N/A":
+ output_cabrillo_line(
+ f"CATEGORY-OVERLAY: {self.contest_settings.get('OverlayCategory','')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"GRID-LOCATOR: {self.station.get('GridSquare','')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"CATEGORY-POWER: {self.contest_settings.get('PowerCategory','')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+
+ output_cabrillo_line(
+ f"CLAIMED-SCORE: {calc_score(self)}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ ops = f"@{self.station.get('Call','')}"
+ list_of_ops = self.database.get_ops()
+ for op in list_of_ops:
+ ops += f", {op.get('Operator', '')}"
+ output_cabrillo_line(
+ f"OPERATORS: {ops}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"NAME: {self.station.get('Name', '')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"ADDRESS: {self.station.get('Street1', '')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"ADDRESS-CITY: {self.station.get('City', '')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"ADDRESS-STATE-PROVINCE: {self.station.get('State', '')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"ADDRESS-POSTALCODE: {self.station.get('Zip', '')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"ADDRESS-COUNTRY: {self.station.get('Country', '')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
+ output_cabrillo_line(
+ f"EMAIL: {self.station.get('Email', '')}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
+ )
for contact in log:
- hiscall = contact.get("Call", "")
- hisname = contact.get("Name", "")
- the_date_and_time = contact.get("TS")
- # band = contact.get("Band")
- themode = contact.get("Mode")
- frequency = str(Decimal(str(contact.get("Freq", 0))) / 1000)
- sentrst = contact.get("SNT", "")
- rcvrst = contact.get("RCV", "")
- sentnr = str(contact.get("SentNr", "0"))
- rcvnr = str(contact.get("NR", "0"))
- grid = contact.get("GridSquare", "")
- comment = contact.get("Comment", "")
+ the_date_and_time = contact.get("TS", "")
+ themode = contact.get("Mode", "")
+ if themode == "LSB" or themode == "USB":
+ themode = "PH"
+ frequency = str(int(contact.get("Freq", "0"))).rjust(5)
+
loggeddate = the_date_and_time[:10]
loggedtime = the_date_and_time[11:13] + the_date_and_time[14:16]
- print(
- f""
- f"{''.join(loggeddate.split('-'))}",
- end="\r\n",
- file=file_descriptor,
+ output_cabrillo_line(
+ f"QSO: {frequency} {themode} {loggeddate} {loggedtime} "
+ f"{contact.get('StationPrefix', '').ljust(13)} "
+ f"{str(contact.get('SNT', '')).ljust(3)} "
+ f"{str(contact.get('SentNr', '')).ljust(6)} "
+ f"{contact.get('Call', '').ljust(13)} "
+ f"{str(contact.get('RCV', '')).ljust(3)} "
+ f"{str(contact.get('NR', '')).ljust(6)}",
+ "\r\n",
+ file_descriptor,
+ file_encoding,
)
-
- try:
- print(
- f"{loggedtime}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- print(
- f"{hiscall.upper()}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- if len(hisname):
- print(
- f"{hisname.title()}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- print(
- f"{themode}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- print(
- f"{frequency}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- print(
- f"{sentrst}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- print(
- f"{rcvrst}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- if sentnr != "0":
- print(
- f"{sentnr}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- if rcvnr != "0":
- print(
- f"{rcvnr}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- if len(grid) > 1:
- print(
- f"{grid}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- try:
- if len(comment):
- print(
- f"{comment}",
- end="\r\n",
- file=file_descriptor,
- )
- except TypeError:
- ...
-
- print("", end="\r\n", file=file_descriptor)
- print("", end="\r\n", file=file_descriptor)
- except IOError:
- ...
-
-
-def cabrillo(self):
- """Generates Cabrillo file. Maybe."""
+ output_cabrillo_line("END-OF-LOG:", "\r\n", file_descriptor, file_encoding)
+ self.show_message_box(f"Cabrillo saved to: {filename}")
+ except IOError as exception:
+ logger.critical("cabrillo: IO error: %s, writing to %s", exception, filename)
+ self.show_message_box(f"Error saving Cabrillo: {exception} {filename}")
+ return
def recalculate_mults(self):
"""Recalculates multipliers after change in logged qso."""
-# States/Prov. DXCC state country
+def process_esm(self, new_focused_widget=None, with_enter=False):
+ """ESM State Machine"""
+
+ # self.pref["run_state"]
+
+ # -----===== Assigned F-Keys =====-----
+ # self.esm_dict["CQ"]
+ # self.esm_dict["EXCH"]
+ # self.esm_dict["QRZ"]
+ # self.esm_dict["AGN"]
+ # self.esm_dict["HISCALL"]
+ # self.esm_dict["MYCALL"]
+ # self.esm_dict["QSOB4"]
+
+ # ----==== text fields ====----
+ # self.callsign
+ # self.sent
+ # self.receive
+ # self.other_1
+ # self.other_2
+
+ if new_focused_widget is not None:
+ self.current_widget = self.inputs_dict.get(new_focused_widget)
+
+ # print(f"checking esm {self.current_widget=} {with_enter=} {self.pref.get("run_state")=}")
+
+ for a_button in [
+ self.esm_dict["CQ"],
+ self.esm_dict["EXCH"],
+ self.esm_dict["QRZ"],
+ self.esm_dict["AGN"],
+ self.esm_dict["HISCALL"],
+ self.esm_dict["MYCALL"],
+ self.esm_dict["QSOB4"],
+ ]:
+ if a_button is not None:
+ self.restore_button_color(a_button)
+
+ buttons_to_send = []
+
+ if self.pref.get("run_state"):
+ if self.current_widget == "callsign":
+ if len(self.callsign.text()) < 3:
+ self.make_button_green(self.esm_dict["CQ"])
+ buttons_to_send.append(self.esm_dict["CQ"])
+ elif len(self.callsign.text()) > 2:
+ self.make_button_green(self.esm_dict["HISCALL"])
+ self.make_button_green(self.esm_dict["EXCH"])
+ buttons_to_send.append(self.esm_dict["HISCALL"])
+ buttons_to_send.append(self.esm_dict["EXCH"])
+
+ elif self.current_widget in ["other_2"]:
+ if self.other_2.text() == "":
+ self.make_button_green(self.esm_dict["AGN"])
+ buttons_to_send.append(self.esm_dict["AGN"])
+ else:
+ self.make_button_green(self.esm_dict["QRZ"])
+ buttons_to_send.append(self.esm_dict["QRZ"])
+ buttons_to_send.append("LOGIT")
+
+ if with_enter is True and bool(len(buttons_to_send)):
+ for button in buttons_to_send:
+ if button:
+ if button == "LOGIT":
+ self.save_contact()
+ continue
+ if button == self.esm_dict["HISCALL"]:
+ self.process_function_key(button, rttysendrx=False)
+ continue
+ self.process_function_key(button)
+ else:
+ if self.current_widget == "callsign":
+ if len(self.callsign.text()) > 2:
+ self.make_button_green(self.esm_dict["MYCALL"])
+ buttons_to_send.append(self.esm_dict["MYCALL"])
+
+ elif self.current_widget in ["other_2"]:
+ if self.other_2.text() == "":
+ self.make_button_green(self.esm_dict["AGN"])
+ buttons_to_send.append(self.esm_dict["AGN"])
+ else:
+ self.make_button_green(self.esm_dict["EXCH"])
+ buttons_to_send.append(self.esm_dict["EXCH"])
+ buttons_to_send.append("LOGIT")
+
+ if with_enter is True and bool(len(buttons_to_send)):
+ for button in buttons_to_send:
+ if button:
+ if button == "LOGIT":
+ self.save_contact()
+ continue
+ self.process_function_key(button)
+
+
+def populate_history_info_line(self):
+ result = self.database.fetch_call_history(self.callsign.text())
+ if result:
+ self.history_info.setText(
+ f"{result.get('Call', '')}, {result.get('Name', '')}, {result.get('State', '')}, {result.get('UserText','...')}"
+ )
+ else:
+ self.history_info.setText("")
+
+
+def check_call_history(self):
+ """"""
+ result = self.database.fetch_call_history(self.callsign.text())
+ if result:
+ self.history_info.setText(f"{result.get('UserText','')}")
+ if self.other_2.text() == "":
+ self.other_2.setText(f"{result.get('State', '')}")
def get_mults(self):
""""""
-
mults = {}
- mults["state"], mults["country"] = show_mults(self, rtc=True)
+ mults["country"], mults["state"] = show_mults(self, rtc=True)
return mults
diff --git a/pyproject.toml b/pyproject.toml
index 15a48eda..56f33975 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "not1mm"
-version = "24.12.29"
+version = "25.1.1"
description = "NOT1MM Logger"
readme = "README.md"
requires-python = ">=3.9"