diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 33c97f60b..9ec3e462e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -25,7 +25,7 @@ If applicable, add screenshots to help explain your problem. **OS, version, khal version and how you installed it:** - The output of khal --version: [e.g. `khal, version 0.11.2.dev20+g0c47162.d20230530` ] - Installation method [e.g. PyPI, git, OS repo] - - python version [e.g. python 3.9] + - python version [e.g. python 3.10] - OS [e.g. arch] - Your khal config file - The versions of your other python packages [e.g. the output of `pip freeze`] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56ed9bf14..10a3ca516 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] tox-test: ["default"] steps: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 820e66583..38c76c137 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,7 +7,7 @@ Package maintainers and users who have to manually update their installation may want to subscribe to `GitHub's tag feed `_. -0.13.1 +0.14.0 ====== unreleased @@ -18,6 +18,7 @@ unreleased * NEW DEPENDENCY sphinxfeed-lsaffre * DROPPED DEPENDENCY sphinxcontrib-newsfeed * NEW support python 3.14 +* DROPPED support for python versions < 3.10. 0.13.0 ====== diff --git a/README.rst b/README.rst index 0757846e5..a5c84ca3a 100644 --- a/README.rst +++ b/README.rst @@ -24,7 +24,7 @@ Features - fast and easy way to add new events - ikhal (interactive khal) lets you browse and edit calendars and events - no support for editing the timezones of events yet -- works with python 3.9+ +- works with python 3.10+ - khal should run on all major operating systems [1]_ .. [1] except for Microsoft Windows diff --git a/doc/source/index.rst b/doc/source/index.rst index d6a571e17..7c2033125 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -17,7 +17,7 @@ Features - ikhal (interactive khal) lets you browse and edit calendars and events - only rudimentary support for creating and editing recursion rules - you cannot edit the timezones of events -- works with python 3.9+ +- works with python 3.10+ - khal should run on all major operating systems [1]_ .. [1] except for Microsoft Windows diff --git a/doc/source/install.rst b/doc/source/install.rst index 67ec861c2..9c8cae7a5 100644 --- a/doc/source/install.rst +++ b/doc/source/install.rst @@ -50,7 +50,7 @@ or better:: in the unpacked distribution folder. -Since version 0.12.0, *khal* **only supports python 3.9+**. If you have +Since version 0.14.0, *khal* **only supports python 3.10+**. If you have python 2 and 3 installed in parallel you might need to use `pip3` instead of `pip` and `python3` instead of `python`. In case your operating system cannot deal with python 2 and 3 packages concurrently, we suggest installing *khal* in @@ -122,7 +122,7 @@ gained. Requirements ------------ -*khal* is written in python and can run on Python 3.9+. It requires a Python +*khal* is written in python and can run on Python 3.10+. It requires a Python with ``sqlite3`` support enabled (which is usually the case). If you are installing python via *pip* or from source, be aware that since diff --git a/khal/_compat.py b/khal/_compat.py deleted file mode 100644 index ca78ab0d7..000000000 --- a/khal/_compat.py +++ /dev/null @@ -1,8 +0,0 @@ -__all__ = ["importlib_metadata"] - -import sys - -if sys.version_info >= (3, 10): # pragma: no cover - from importlib import metadata as importlib_metadata -else: # pragma: no cover - import importlib_metadata diff --git a/khal/calendar_display.py b/khal/calendar_display.py index 435c4cc6a..7f120c3e8 100644 --- a/khal/calendar_display.py +++ b/khal/calendar_display.py @@ -22,7 +22,6 @@ import calendar import datetime as dt from locale import LC_ALL, LC_TIME, getlocale, setlocale -from typing import Optional, Union from click import style @@ -82,7 +81,7 @@ def get_color_list( def str_highlight_day( day: dt.date, calendars: list[str], - hmethod: Optional[str], + hmethod: str | None, default_color: str, multiple: str, multiple_on_overflow: bool, @@ -122,8 +121,8 @@ def str_highlight_day( def str_week( week: list[dt.date], today: dt.date, - collection: Optional[CalendarCollection]=None, - hmethod: Optional[str]=None, + collection: CalendarCollection | None=None, + hmethod: str | None=None, default_color: str='', multiple: str='', multiple_on_overflow: bool=False, @@ -165,10 +164,10 @@ def str_week( return strweek -def vertical_month(month: Optional[int]=None, - year: Optional[int]=None, - today: Optional[dt.date]=None, - weeknumber: Union[bool, str]=False, +def vertical_month(month: int | None=None, + year: int | None=None, + today: dt.date | None=None, + weeknumber: bool | str=False, count: int=3, firstweekday: int=0, monthdisplay: str='firstday', diff --git a/khal/controllers.py b/khal/controllers.py index 4cd774d8e..e4218ca46 100644 --- a/khal/controllers.py +++ b/khal/controllers.py @@ -26,8 +26,8 @@ import re import textwrap from collections import OrderedDict, defaultdict +from collections.abc import Callable from shutil import get_terminal_size -from typing import Callable, Optional import pytz from click import confirm, echo, prompt, style @@ -237,16 +237,16 @@ def get_events_between( def khal_list( collection, - daterange: Optional[list[str]] = None, - conf: Optional[dict] = None, + daterange: list[str] | None = None, + conf: dict | None = None, agenda_format=None, - day_format: Optional[str]=None, + day_format: str | None=None, once=False, notstarted: bool = False, - width: Optional[int] = None, + width: int | None = None, env=None, datepoint=None, - json: Optional[list] = None, + json: list | None = None, ): """returns a list of all events in `daterange`""" assert daterange is not None or datepoint is not None @@ -438,7 +438,7 @@ def new_from_dict( event_args: EventCreationTypes, collection: CalendarCollection, conf, - calendar_name: Optional[str]=None, + calendar_name: str | None=None, format=None, env=None, json=None, diff --git a/khal/custom_types.py b/khal/custom_types.py index d0a2a8041..ecdbf6ed0 100644 --- a/khal/custom_types.py +++ b/khal/custom_types.py @@ -1,6 +1,6 @@ import datetime as dt import os -from typing import Literal, Optional, Protocol, TypedDict, Union +from typing import Literal, Protocol, TypedDict import pytz @@ -23,14 +23,14 @@ class LocaleConfiguration(TypedDict): longdateformat: str datetimeformat: str longdatetimeformat: str - weeknumbers: Union[str, bool] + weeknumbers: str | bool firstweekday: int unicode_symbols: bool class SupportsRaw(Protocol): @property - def uid(self) -> Optional[str]: + def uid(self) -> str | None: ... @property @@ -42,8 +42,8 @@ def raw(self) -> str: EventTuple = tuple[ str, str, - Union[dt.date, dt.datetime], - Union[dt.date, dt.datetime], + dt.date | dt.datetime, + dt.date | dt.datetime, str, str, str, @@ -68,16 +68,16 @@ class EventCreationTypes(TypedDict): summary: str description: str allday: bool - location: Optional[str] - categories: Optional[Union[str, list[str]]] - repeat: Optional[str] + location: str | None + categories: str | list[str] | None + repeat: str | None until: str alarms: str timezone: pytz.BaseTzInfo url: str -PathLike = Union[str, os.PathLike] +PathLike = str| os.PathLike WeekNumbersType = Literal['left', 'right', False] MonthDisplayType = Literal['firstday', 'firstfullweek'] diff --git a/khal/icalendar.py b/khal/icalendar.py index 662cc9cf4..65ccdd7b8 100644 --- a/khal/icalendar.py +++ b/khal/icalendar.py @@ -25,7 +25,6 @@ import logging from collections import defaultdict from hashlib import sha256 -from typing import Optional, Union import dateutil.rrule import icalendar @@ -94,15 +93,15 @@ def new_vevent(locale, dtstart: dt.date, dtend: dt.date, summary: str, - timezone: Optional[pytz.BaseTzInfo]=None, + timezone: pytz.BaseTzInfo | None=None, allday: bool=False, - description: Optional[str]=None, - location: Optional[str]=None, - categories: Optional[Union[list[str], str]]=None, - repeat: Optional[str]=None, + description: str | None=None, + location: str | None=None, + categories: list[str] | str | None=None, + repeat: str | None=None, until=None, - alarms: Optional[str]=None, - url: Optional[str]=None, + alarms: str | None=None, + url: str | None=None, ) -> icalendar.Event: """create a new event @@ -217,7 +216,7 @@ def ics_from_list( def expand( vevent: icalendar.Event, href: str='', -) -> Optional[list[tuple[dt.datetime, dt.datetime]]]: +) -> list[tuple[dt.datetime, dt.datetime]] | None: """ Constructs a list of start and end dates for all recurring instances of the event defined in vevent. diff --git a/khal/khalendar/backend.py b/khal/khalendar/backend.py index e076dbc3a..f0387fb7c 100644 --- a/khal/khalendar/backend.py +++ b/khal/khalendar/backend.py @@ -29,7 +29,7 @@ from collections.abc import Iterable, Iterator from enum import IntEnum from os import makedirs, path -from typing import Any, Optional, Union +from typing import Any import icalendar import icalendar.cal @@ -76,7 +76,7 @@ class SQLiteDb: def __init__(self, calendars: Iterable[str], - db_path: Optional[str], + db_path: str | None, locale: LocaleConfiguration, ) -> None: assert db_path is not None @@ -203,7 +203,7 @@ def update(self, vevent_str: str, href: str, etag: str='', - calendar: Optional[str]=None, + calendar: str | None=None, ) -> None: """insert a new or update an existing event into the db @@ -252,7 +252,7 @@ def update(self, self.sql_ex(sql_s, stuple) def update_vcf_dates(self, vevent_str: str, href: str, etag: str='', - calendar: Optional[str]=None) -> None: + calendar: str | None=None) -> None: """insert events from a vcard into the db This is will parse BDAY, ANNIVERSARY, X-ANNIVERSARY and X-ABDATE fields. @@ -408,7 +408,7 @@ def _update_impl(self, vevent: icalendar.cal.Event, href: str, calendar: str) -> stuple_n = (dbstart, dbend, href, ref, dtype, rec_inst, calendar) self.sql_ex(recs_sql_s, stuple_n) - def get_ctag(self, calendar: str) -> Optional[str]: + def get_ctag(self, calendar: str) -> str | None: stuple = (calendar, ) sql_s = 'SELECT ctag FROM calendars WHERE calendar = ?;' try: @@ -423,7 +423,7 @@ def set_ctag(self, ctag: str, calendar: str) -> None: self.sql_ex(sql_s, stuple) self.conn.commit() - def get_etag(self, href: str, calendar: str) -> Optional[str]: + def get_etag(self, href: str, calendar: str) -> str | None: """get etag for href return: etag @@ -548,8 +548,8 @@ def get_floating(self, start: dt.datetime, end: dt.datetime) -> Iterable[EventTu """return floating events between `start` and `end`""" assert start.tzinfo is None assert end.tzinfo is None - start_dt: Union[dt.datetime, dt.date] - end_dt: Union[dt.datetime, dt.date] + start_dt: dt.datetime | dt.date + end_dt: dt.datetime | dt.date start_u = utils.to_unix_time(start) end_u = utils.to_unix_time(end) diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py index 3af1531cd..b8a914ab7 100644 --- a/khal/khalendar/event.py +++ b/khal/khalendar/event.py @@ -25,7 +25,7 @@ import datetime as dt import logging import os -from typing import Callable, Optional, Union +from collections.abc import Callable import icalendar import icalendar.cal @@ -61,15 +61,15 @@ class Event: def __init__(self, vevents: dict[str, icalendar.Event], locale: LocaleConfiguration, - ref: Optional[str] = None, + ref: str | None = None, readonly: bool = False, - href: Optional[str] = None, - etag: Optional[str] = None, - calendar: Optional[str] = None, - color: Optional[str] = None, - start: Optional[dt.datetime] = None, - end: Optional[dt.datetime] = None, - addresses: Optional[list[str]] =None, + href: str | None = None, + etag: str | None = None, + calendar: str | None = None, + color: str | None = None, + start: dt.datetime | None = None, + end: dt.datetime | None = None, + addresses: list[str] | None =None, ): """ :param start: start datetime of this event instance @@ -128,8 +128,8 @@ def _get_type_from_date(cls, start: dt.datetime) -> type['Event']: @classmethod def fromVEvents(cls, events_list: list[icalendar.Event], - ref: Optional[str]=None, - start: Optional[dt.datetime]=None, + ref: str | None=None, + start: dt.datetime | None=None, **kwargs) -> 'Event': assert isinstance(events_list, list) @@ -263,7 +263,7 @@ def update_rrule(self, rrule: str) -> None: self._vevents['PROTO'].add('RRULE', rrule) @property - def recurrence_id(self) -> Union[dt.datetime, str]: + def recurrence_id(self) -> dt.datetime | str: """return the "original" start date of this event (i.e. their recurrence-id) """ if self.ref == 'PROTO': @@ -594,7 +594,7 @@ def _partstat_str(self) -> str: def attributes( self, - relative_to: Union[tuple[dt.date, dt.date], dt.date], + relative_to: tuple[dt.date, dt.date] | dt.date, env=None, colors: bool=True, ): @@ -814,7 +814,7 @@ def status(self) -> str: return self._vevents[self.ref].get('STATUS', '') @property - def partstat(self) -> Optional[str]: + def partstat(self) -> str | None: for attendee in self._vevents[self.ref].get('ATTENDEE', []): for address in self.addresses: if attendee == 'mailto:' + address: @@ -920,8 +920,8 @@ def duration(self) -> dt.timedelta: def create_timezone( tz: pytz.BaseTzInfo, - first_date: Optional[dt.datetime]=None, - last_date: Optional[dt.datetime]=None + first_date: dt.datetime | None=None, + last_date: dt.datetime | None=None ) -> icalendar.Timezone: """ create an icalendar vtimezone from a pytz.tzinfo object diff --git a/khal/khalendar/exceptions.py b/khal/khalendar/exceptions.py index d124a83f9..cf7a90e2e 100644 --- a/khal/khalendar/exceptions.py +++ b/khal/khalendar/exceptions.py @@ -19,7 +19,6 @@ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -from typing import Optional from khal.exceptions import Error, FatalError, UnsupportedFeatureError @@ -65,7 +64,7 @@ class UpdateFailed(Error): class DuplicateUid(Error): """an event with this UID already exists""" - existing_href: Optional[str] = None + existing_href: str | None = None class NonUniqueUID(Error): diff --git a/khal/khalendar/khalendar.py b/khal/khalendar/khalendar.py index 85fd7276f..dd971b892 100644 --- a/khal/khalendar/khalendar.py +++ b/khal/khalendar/khalendar.py @@ -31,7 +31,6 @@ import os import os.path from collections.abc import Iterable -from typing import Optional, Union from khal.custom_types import CalendarConfiguration, EventCreationTypes, LocaleConfiguration from khal.icalendar import new_vevent @@ -71,15 +70,15 @@ def __init__(self, color: str='', priority: int=10, highlight_event_days: bool=False, - locale: Optional[LocaleConfiguration]=None, - dbpath: Optional[str]=None, + locale: LocaleConfiguration | None=None, + dbpath: str | None=None, ) -> None: assert locale assert dbpath is not None assert calendars is not None self._calendars: dict[str, CalendarConfiguration] = calendars - self._default_calendar_name: Optional[str] = None + self._default_calendar_name: str | None = None self._storages: dict[str, Vdir] = {} file_ext: str @@ -123,7 +122,7 @@ def names(self) -> Iterable[str]: return self._calendars.keys() @property - def default_calendar_name(self) -> Optional[str]: + def default_calendar_name(self) -> str | None: return self._default_calendar_name @default_calendar_name.setter @@ -184,7 +183,7 @@ def update(self, event: Event) -> None: self._backend.update(event.raw, event.href, event.etag, calendar=event.calendar) self._backend.set_ctag(self._local_ctag(event.calendar), calendar=event.calendar) - def force_update(self, event: Event, collection: Optional[str]=None) -> None: + def force_update(self, event: Event, collection: str | None=None) -> None: """update `event` even if an event with the same uid/href already exists""" href: str calendar = collection if collection is not None else event.calendar @@ -202,7 +201,7 @@ def force_update(self, event: Event, collection: Optional[str]=None) -> None: self._backend.update(event.raw, href, etag, calendar=calendar) self._backend.set_ctag(self._local_ctag(calendar), calendar=calendar) - def insert(self, event: Event, collection: Optional[str]=None) -> None: + def insert(self, event: Event, collection: str | None=None) -> None: """Insert a new event to the vdir and the database The event will get a new href and etag properties. If ``collection`` is @@ -230,7 +229,7 @@ def insert(self, event: Event, collection: Optional[str]=None) -> None: self._backend.update(event.raw, event.href, event.etag, calendar=calendar) self._backend.set_ctag(self._local_ctag(calendar), calendar=calendar) - def delete(self, href: str, etag: Optional[str], calendar: str) -> None: + def delete(self, href: str, etag: str | None, calendar: str) -> None: """Delete an event specified by `href` from `calendar`""" if self._calendars[calendar]['readonly']: raise ReadOnlyCalendarError() @@ -242,7 +241,7 @@ def delete(self, href: str, etag: Optional[str], calendar: str) -> None: def delete_instance(self, href: str, - etag: Optional[str], + etag: str | None, calendar: str, rec_id: dt.datetime, ) -> Event: @@ -268,11 +267,11 @@ def get_event(self, href: str, calendar: str) -> Event: def _construct_event(self, item: str, href: str, - start: Optional[Union[dt.datetime, dt.date]] = None, - end: Optional[Union[dt.datetime, dt.date]] = None, + start: dt.datetime | dt.date | None = None, + end: dt.datetime | dt.date | None = None, ref: str='PROTO', - etag: Optional[str]=None, - calendar: Optional[str]=None, + etag: str | None=None, + calendar: str | None=None, ) -> Event: assert calendar is not None event = Event.fromString( @@ -302,8 +301,8 @@ def change_collection(self, event: Event, new_collection: str) -> None: def create_event_from_ics(self, ical: str, calendar_name: str, - etag: Optional[str]=None, - href: Optional[str]=None, + etag: str | None=None, + href: str | None=None, ) -> Event: """creates and returns (but does not insert) a new event from ical string""" @@ -312,7 +311,7 @@ def create_event_from_ics(self, def create_event_from_dict(self, event_dict: EventCreationTypes, - calendar_name: Optional[str] = None, + calendar_name: str | None = None, ) -> Event: """Creates an Event from the method's arguments """ @@ -401,7 +400,7 @@ def _update_vevent(self, href: str, calendar: str) -> bool: update(event.raw, href=href, etag=etag, calendar=calendar) return True except Exception as e: - if not isinstance(e, (UpdateFailed, UnsupportedFeatureError, NonUniqueUID)): + if not isinstance(e, UpdateFailed | UnsupportedFeatureError | NonUniqueUID): logger.exception('Unknown exception happened.') logger.warning( f'Skipping {calendar}/{href}: {e!s}\n' @@ -412,7 +411,7 @@ def search(self, search_string: str) -> Iterable[Event]: """search for the db for events matching `search_string`""" return (self._construct_event(*args) for args in self._backend.search(search_string)) - def get_day_styles(self, day: dt.date, focus: bool) -> Optional[Union[str, tuple[str, str]]]: + def get_day_styles(self, day: dt.date, focus: bool) -> str | tuple[str, str] | None: calendars = self.get_calendars_on(day) if len(calendars) == 0: return None @@ -424,7 +423,7 @@ def get_day_styles(self, day: dt.date, focus: bool) -> Optional[Union[str, tuple return 'highlight_days_multiple' return ('calendar ' + calendars[0], 'calendar ' + calendars[1]) - def get_styles(self, date: dt.date, focus: bool) -> Optional[Union[str, tuple[str, str]]]: + def get_styles(self, date: dt.date, focus: bool) -> str | tuple[str, str] | None: if focus: if date == date.today(): return 'today focus' diff --git a/khal/khalendar/typing.py b/khal/khalendar/typing.py index b863c7e50..65f58e563 100644 --- a/khal/khalendar/typing.py +++ b/khal/khalendar/typing.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from khal.khalendar.event import Event diff --git a/khal/khalendar/vdir.py b/khal/khalendar/vdir.py index b85d7371e..be191e97f 100644 --- a/khal/khalendar/vdir.py +++ b/khal/khalendar/vdir.py @@ -8,9 +8,9 @@ import os import tempfile import uuid -from collections.abc import Iterable +from collections.abc import Callable, Iterable from hashlib import sha1 -from typing import IO, Callable, Optional, Protocol +from typing import IO, Protocol from khal.custom_types import PathLike, SupportsRaw @@ -52,7 +52,7 @@ def _href_safe(uid: str, safe: str=SAFE_UID_CHARS) -> bool: return not bool(set(uid) - set(safe)) -def _generate_href(uid: Optional[str]=None, safe: str=SAFE_UID_CHARS) -> str: +def _generate_href(uid: str | None=None, safe: str=SAFE_UID_CHARS) -> str: if not uid: return str(uuid.uuid4().hex) elif not _href_safe(uid, safe): @@ -126,7 +126,7 @@ def __init__(self, raw: str) -> None: self.raw = raw @cached_property - def uid(self) -> Optional[str]: + def uid(self) -> str | None: uid = '' lines = iter(self.raw.splitlines()) for line in lines: @@ -205,7 +205,7 @@ def create(cls, collection_name: PathLike, **kwargs: PathLike) -> dict[str, Path def _get_filepath(self, href: str) -> str: return os.path.join(self.path, href) - def _get_href(self, uid: Optional[str]) -> str: + def _get_href(self, uid: str | None) -> str: return _generate_href(uid) + self.fileext def list(self) -> Iterable[tuple[str, str]]: @@ -277,7 +277,7 @@ def update(self, href: str, item: SupportsRaw, etag: str) -> str: return etag - def delete(self, href: str, etag: Optional[str]) -> None: + def delete(self, href: str, etag: str | None) -> None: fpath = self._get_filepath(href) if not os.path.isfile(fpath): raise NotFoundError(href) @@ -286,7 +286,7 @@ def delete(self, href: str, etag: Optional[str]) -> None: raise WrongEtagError(etag, actual_etag) os.remove(fpath) - def get_meta(self, key: str) -> Optional[str]: + def get_meta(self, key: str) -> str | None: fpath = os.path.join(self.path, key) try: with open(fpath, 'rb') as f: @@ -333,7 +333,7 @@ def rgb(self) -> tuple[int, int, int]: class ColorMixin: color_type: type[Color] = Color - def get_color(self: HasMetaProtocol) -> Optional[str]: + def get_color(self: HasMetaProtocol) -> str | None: try: return self.color_type(self.get_meta('color')) except ValueError: diff --git a/khal/parse_datetime.py b/khal/parse_datetime.py index dff5937b4..fc73556dc 100644 --- a/khal/parse_datetime.py +++ b/khal/parse_datetime.py @@ -26,8 +26,9 @@ import logging import re from calendar import isleap +from collections.abc import Callable from time import strptime -from typing import Any, Callable, Optional, Union +from typing import Any import pytz @@ -57,7 +58,7 @@ def timefstr(dtime_list: list[str], timeformat: str) -> dt.datetime: def datetimefstr( dtime_list: list[str], dateformat: str, - default_day: Optional[dt.date]=None, + default_day: dt.date | None=None, infer_year: bool=True, in_future: bool=True, ) -> dt.datetime: @@ -193,7 +194,7 @@ def datetimefstr_weekday(dtime_list: list[str], timeformat: str, infer_year: boo def guessdatetimefstr( dtime_list: list[str], locale: LocaleConfiguration, - default_day: Optional[dt.date]=None, + default_day: dt.date | None=None, in_future=True, ) -> tuple[dt.datetime, bool]: """ @@ -321,7 +322,7 @@ def guesstimedeltafstr(delta_string: str) -> dt.timedelta: return res -def guessrangefstr(daterange: Union[str, list[str]], +def guessrangefstr(daterange: str | list[str], locale: LocaleConfiguration, default_timedelta_date: dt.timedelta=dt.timedelta(days=1), default_timedelta_datetime: dt.timedelta=dt.timedelta(hours=1), @@ -422,7 +423,7 @@ def guessrangefstr(daterange: Union[str, list[str]], def rrulefstr(repeat: str, until: str, locale: LocaleConfiguration, - timezone: Optional[dt.tzinfo], + timezone: dt.tzinfo | None, ) -> RRuleMapType: if repeat in ["daily", "weekly", "monthly", "yearly"]: rrule_settings: RRuleMapType = {'freq': repeat} @@ -457,9 +458,9 @@ def eventinfofstr(info_string: str, parts = info_string.split(' ') summary = None - start: Optional[Union[dt.datetime, dt.date]] = None - end: Optional[Union[dt.datetime, dt.date]] = None - tz: Optional[pytz.BaseTzInfo] = None + start: dt.datetime | dt.date | None = None + end: dt.datetime | dt.date | None = None + tz: pytz.BaseTzInfo | None = None allday: bool = False for i in reversed(range(1, len(parts) + 1)): try: diff --git a/khal/plugins.py b/khal/plugins.py index a7ef89b7d..440ae3aa8 100644 --- a/khal/plugins.py +++ b/khal/plugins.py @@ -1,7 +1,5 @@ -from collections.abc import Mapping -from typing import Callable - -from khal._compat import importlib_metadata +from collections.abc import Callable, Mapping +from importlib import metadata as importlib_metadata # This is a shameless ripoff of mdformat's plugin extension API. # see: diff --git a/khal/settings/settings.py b/khal/settings/settings.py index e42addaad..7063da887 100644 --- a/khal/settings/settings.py +++ b/khal/settings/settings.py @@ -34,7 +34,7 @@ except ModuleNotFoundError: from validate import Validator -from typing import Callable, Optional +from collections.abc import Callable from .exceptions import CannotParseConfigFileError, InvalidSettingsError, NoConfigFile from .utils import ( @@ -54,7 +54,7 @@ SPECPATH = os.path.join(os.path.dirname(__file__), 'khal.spec') -def find_configuration_file() -> Optional[str]: +def find_configuration_file() -> str | None: """Return the configuration filename. Check all the paths for configuration files defined in the XDG Base Directory @@ -73,7 +73,7 @@ def find_configuration_file() -> Optional[str]: def get_config( - config_path: Optional[str]=None, + config_path: str | None=None, _get_color_from_vdir: Callable=get_color_from_vdir, _get_vdir_type: Callable=get_vdir_type) -> ConfigObj: """reads the config file, validates it and return a config dict diff --git a/khal/settings/utils.py b/khal/settings/utils.py index f7f4db935..910b17fe6 100644 --- a/khal/settings/utils.py +++ b/khal/settings/utils.py @@ -25,9 +25,9 @@ import logging import os import pathlib -from collections.abc import Iterable +from collections.abc import Callable, Iterable from os.path import expanduser, expandvars, join -from typing import Callable, Literal, Optional, Union +from typing import Literal import pytz import xdg @@ -48,7 +48,7 @@ logger = logging.getLogger('khal') -def is_timezone(tzstring: Optional[str]) -> dt.tzinfo: +def is_timezone(tzstring: str | None) -> dt.tzinfo: """tries to convert tzstring into a pytz timezone or return local timezone raises a VdtvalueError if tzstring is not valid @@ -71,7 +71,7 @@ def is_timedelta(string: str) -> dt.timedelta: raise VdtValueError(f"Invalid timedelta: {string}") -def weeknumber_option(option: str) -> Union[Literal['left', 'right'], Literal[False]]: +def weeknumber_option(option: str) -> Literal['left', 'right'] | Literal[False]: """checks if *option* is a valid value :param option: the option the user set in the config file @@ -157,7 +157,7 @@ def test_default_calendar(config) -> None: raise InvalidSettingsError() -def get_color_from_vdir(path: str) -> Optional[str]: +def get_color_from_vdir(path: str) -> str | None: try: color = Vdir(path, '.ics').get_meta('color') except CollectionNotFoundError: diff --git a/khal/terminal.py b/khal/terminal.py index 679eb9e08..285994910 100644 --- a/khal/terminal.py +++ b/khal/terminal.py @@ -22,7 +22,7 @@ """all functions related to terminal display are collected here""" from itertools import zip_longest -from typing import NamedTuple, Optional +from typing import NamedTuple class NamedColor(NamedTuple): @@ -55,8 +55,8 @@ class NamedColor(NamedTuple): def get_color( - fg: Optional[str]=None, - bg: Optional[str]=None, + fg: str | None=None, + bg: str | None=None, bold_for_light_color: bool=False, ) -> str: """convert foreground and/or background color in ANSI color codes @@ -120,8 +120,8 @@ def get_color( def colored( string: str, - fg: Optional[str]=None, - bg: Optional[str]=None, + fg: str | None=None, + bg: str | None=None, bold_for_light_color: bool=True, ) -> str: """colorize `string` with ANSI color codes diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 9cb3ec443..b9f16177d 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -24,7 +24,7 @@ import signal import sys from enum import IntEnum -from typing import Literal, Optional +from typing import Literal import click import urwid @@ -90,7 +90,7 @@ class SelectableText(urwid.Text): def selectable(self) -> bool: return True - def keypress(self, size: tuple[int], key: Optional[str]) -> Optional[str]: + def keypress(self, size: tuple[int], key: str | None) -> str | None: return key def get_cursor_coords(self, size: tuple[int]) -> tuple[int, int]: @@ -143,7 +143,7 @@ def relative_day(self, day: dt.date, dtformat: str) -> str: return f'{weekday}, {daystr} ({approx_delta})' - def keypress(self, size: tuple[int], key: Optional[str]) -> Optional[str]: + def keypress(self, size: tuple[int], key: str | None) -> str | None: binds = self._conf['keybindings'] if key in binds['left']: key = 'left' @@ -223,7 +223,7 @@ def set_title(self, mark: str=' ') -> None: self.set_text(mark + ' ' + text.replace('\n', newline)) - def keypress(self, size: tuple[int], key: Optional[str]) -> Optional[str]: + def keypress(self, size: tuple[int], key: str | None) -> str | None: binds = self._conf['keybindings'] if key in binds['left']: key = 'left' @@ -254,11 +254,11 @@ def __init__( self.set_focus_date_callback = set_focus_date_callback super().__init__(*args, **kwargs) - def keypress(self, size: tuple[int], key: Optional[str]) -> Optional[str]: + def keypress(self, size: tuple[int], key: str | None) -> str | None: return super().keypress(size, key) @property - def focus_event(self) -> Optional[U_Event]: + def focus_event(self) -> U_Event | None: if self.focus is None: return None else: @@ -310,7 +310,7 @@ def ensure_date(self, day: dt.date) -> None: self.body.ensure_date(day) self.clean() - def keypress(self, size: tuple[int], key: Optional[str]) -> Optional[str]: + def keypress(self, size: tuple[int], key: str | None) -> str | None: if key in self._conf['keybindings']['up']: key = 'up' if key in self._conf['keybindings']['down']: @@ -614,7 +614,7 @@ def set_selected_date(self, day: dt.date) -> None: title.set_text(title.get_text()[0]) @property - def focus_event(self) -> Optional[U_Event]: + def focus_event(self) -> U_Event | None: if self.body.focus == 0: return None else: @@ -642,8 +642,8 @@ def __init__(self, elistbox, pane) -> None: self._conf = pane._conf self.divider = urwid.Divider('─') self.editor = False - self._last_focused_date: Optional[dt.date] = None - self._eventshown: Optional[tuple[str, str]] = None + self._last_focused_date: dt.date | None = None + self._eventshown: tuple[str, str] | None = None self.event_width = int(self.pane._conf['view']['event_view_weighting']) self.delete_status = pane.delete_status self.toggle_delete_all = pane.toggle_delete_all @@ -653,7 +653,7 @@ def __init__(self, elistbox, pane) -> None: urwid.WidgetWrap.__init__(self, self.container) @property - def focus_event(self) -> Optional[U_Event]: + def focus_event(self) -> U_Event | None: """returns the event currently in focus""" return self.dlistbox.focus_event @@ -883,7 +883,7 @@ def duplicate(self) -> None: except IndexError: pass - def new(self, date: dt.date, end: Optional[dt.date]=None) -> None: + def new(self, date: dt.date, end: dt.date | None=None) -> None: """create a new event on `date` at the next full hour and edit it :param date: default date for new event @@ -917,7 +917,7 @@ def new(self, date: dt.date, end: Optional[dt.date]=None) -> None: def selectable(self): return True - def keypress(self, size: tuple[int], key: Optional[str]) -> Optional[str]: + def keypress(self, size: tuple[int], key: str | None) -> str | None: prev_shown = self._eventshown self._eventshown = None self.clear_event_view() @@ -1028,7 +1028,7 @@ def __init__(self, search_func, abort_func) -> None: class Search(Edit): - def keypress(self, size: tuple[int], key: Optional[str]) -> Optional[str]: + def keypress(self, size: tuple[int], key: str | None) -> str | None: if key == 'enter': search_func(self.text) return None @@ -1115,7 +1115,7 @@ def __init__(self, collection, conf=None, title: str='', description: str='') -> ) Pane.__init__(self, columns, title=title, description=description) - def delete_status(self, uid: str) -> Optional[DeletionType]: + def delete_status(self, uid: str) -> DeletionType | None: if uid[0] in self._deleted[DeletionType.ALL]: return DeletionType.ALL elif uid in self._deleted[DeletionType.INSTANCES]: @@ -1153,7 +1153,7 @@ def cleanup(self, data): event = self.collection.delete_instance(href, etag, account, rec_id) updated_etags[event.href] = event.etag - def keypress(self, size: tuple[int], key: Optional[str]) -> Optional[str]: + def keypress(self, size: tuple[int], key: str | None) -> str | None: binds = self._conf['keybindings'] if key in binds['search']: self.search() @@ -1292,7 +1292,7 @@ def _add_calendar_colors( palette: list[tuple[str, ...]], collection: 'CalendarCollection', color_mode: Literal['256colors', 'rgb'], - base: Optional[str] = None, + base: str | None = None, attr_template: str = 'calendar {}', ) -> list[tuple[str, ...]]: """Add the colors for the defined calendars to the palette. diff --git a/khal/ui/base.py b/khal/ui/base.py index 032b0eade..cc31a9ba0 100644 --- a/khal/ui/base.py +++ b/khal/ui/base.py @@ -28,7 +28,7 @@ import logging import threading import time -from typing import Callable +from collections.abc import Callable import urwid diff --git a/khal/ui/calendarwidget.py b/khal/ui/calendarwidget.py index 8b6d72bba..f242e6537 100644 --- a/khal/ui/calendarwidget.py +++ b/khal/ui/calendarwidget.py @@ -26,8 +26,9 @@ import calendar import datetime as dt +from collections.abc import Callable from locale import LC_ALL, LC_TIME, getlocale, setlocale -from typing import Any, Callable, Literal, Optional, TypedDict, Union +from typing import Any, Literal, TypedDict import urwid @@ -38,8 +39,8 @@ class MarkType(TypedDict): date: dt.date pos: tuple[int, int] -OnPressType = dict[str, Callable[[dt.date, Optional[dt.date]], Optional[str]]] -GetStylesSignature = Callable[[dt.date, bool], Optional[Union[str, tuple[str, str]]]] +OnPressType = dict[str, Callable[[dt.date, dt.date | None], str | None]] +GetStylesSignature = Callable[[dt.date, bool], str | tuple[str, str] | None] setlocale(LC_ALL, '') @@ -90,7 +91,7 @@ def __init__(self, date: dt.date, get_styles: GetStylesSignature) -> None: self._get_styles = get_styles super().__init__(urwid.Columns(self.halves)) - def set_styles(self, styles: Union[None, str, tuple[str, str]]) -> None: + def set_styles(self, styles: None | str | tuple[str, str]) -> None: """If single string, sets the same style for both halves, if two strings, sets different style for each half. """ @@ -237,8 +238,8 @@ def __init__(self, walker: 'CalendarWalker') -> None: self._init: bool = True self.keybindings = walker.keybindings self.on_press = walker.on_press - self._marked: Optional[MarkType] = None - self._pos_old: Optional[tuple[int, int]] = None + self._marked: MarkType | None = None + self._pos_old: tuple[int, int] | None = None self.body: CalendarWalker super().__init__(walker) @@ -281,7 +282,7 @@ def _mark_one(self, row: int, column: int) -> None: """set attribute *mark* on the date at row `row` and column `column`""" self.body[row].contents[column][0].set_styles('mark') - def _mark(self, a_date: Optional[dt.date]=None) -> None: + def _mark(self, a_date: dt.date | None=None) -> None: """make sure everything between the marked entry and `a_date` is visually marked, and nothing else""" @@ -333,7 +334,7 @@ def set_focus_date(self, a_day: dt.date) -> None: self._mark(a_day) self.body.set_focus_date(a_day) - def keypress(self, size: bool, key: str) -> Optional[str]: + def keypress(self, size: bool, key: str) -> str | None: if key in self.keybindings['mark'] + ['esc'] and self._marked: self._unmark_all() self._marked = None @@ -371,13 +372,13 @@ def keypress(self, size: bool, key: str) -> Optional[str]: class CalendarWalker(urwid.SimpleFocusListWalker): def __init__(self, on_date_change: Callable[[dt.date], None], - on_press: dict[str, Callable[[dt.date, Optional[dt.date]], Optional[str]]], + on_press: dict[str, Callable[[dt.date, dt.date | None], str | None]], keybindings: dict[str, list[str]], get_styles: GetStylesSignature, firstweekday: int = 0, weeknumbers: Literal['left', 'right', False]=False, monthdisplay: Literal['firstday', 'firstfullweek']='firstday', - initial: Optional[dt.date]=None, + initial: dt.date | None=None, ) -> None: self.firstweekday = firstweekday self.weeknumbers = weeknumbers @@ -388,7 +389,7 @@ def __init__(self, self.get_styles = get_styles self.reset(initial) - def reset(self, initial: Optional[dt.date]=None) -> None: + def reset(self, initial: dt.date | None=None) -> None: if initial is None: initial = dt.date.today() weeks = self._construct_month(initial.year, initial.month) @@ -532,7 +533,7 @@ def _construct_week(self, week: list[dt.date]) -> DateCColumns: month_name = ' ' attr = None - this_week: list[tuple[int, Union[Date, urwid.AttrMap]]] + this_week: list[tuple[int, Date | urwid.AttrMap]] this_week = [(get_month_abbr_len(), urwid.AttrMap(urwid.Text(month_name), attr))] for _number, day in enumerate(week): new_date = Date(day, self.get_styles) @@ -586,12 +587,12 @@ class CalendarWidget(urwid.WidgetWrap): def __init__(self, on_date_change: Callable[[dt.date], None], keybindings: dict[str, list[str]], - on_press: Optional[OnPressType]=None, + on_press: OnPressType | None=None, firstweekday: int=0, weeknumbers: Literal['left', 'right', False]=False, monthdisplay: Literal['firstday', 'firstfullweek']='firstday', - get_styles: Optional[GetStylesSignature]=None, - initial: Optional[dt.date]=None, + get_styles: GetStylesSignature | None=None, + initial: dt.date | None=None, ) -> None: """A calendar widget that can be used in urwid applications @@ -653,7 +654,7 @@ def __init__(self, weekheader = _calendar.formatweekheader(2) dnames = weekheader.split(' ') - def _get_styles(date: dt.date, focus: bool) -> Optional[str]: + def _get_styles(date: dt.date, focus: bool) -> str | None: if focus: if date == dt.date.today(): return 'today focus' diff --git a/khal/ui/editor.py b/khal/ui/editor.py index fd255642e..b6ae9ecde 100644 --- a/khal/ui/editor.py +++ b/khal/ui/editor.py @@ -20,7 +20,8 @@ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import datetime as dt -from typing import TYPE_CHECKING, Callable, Literal, Optional +from collections.abc import Callable +from typing import TYPE_CHECKING, Literal import urwid @@ -115,7 +116,7 @@ def __init__( weeknumbers: Literal['left', 'right', False]=False, firstweekday: int=0, monthdisplay: Literal['firstday', 'firstfullweek']='firstday', - keybindings: Optional[dict[str, list[str]]] = None, + keybindings: dict[str, list[str]] | None = None, ) -> None: datewidth = len(startdt.strftime(dateformat)) self._dateformat = dateformat @@ -603,7 +604,7 @@ def save(self, button): self._abort_confirmed = False self.pane.window.backtrack() - def keypress(self, size: tuple[int], key: str) -> Optional[str]: + def keypress(self, size: tuple[int], key: str) -> str | None: if key in ['esc'] and self.changed and not self._abort_confirmed: self.pane.window.alert( ('light red', 'Unsaved changes! Hit ESC again to discard.')) diff --git a/khal/ui/widgets.py b/khal/ui/widgets.py index cddb96d9f..5dd794309 100644 --- a/khal/ui/widgets.py +++ b/khal/ui/widgets.py @@ -26,7 +26,6 @@ """ import datetime as dt import re -from typing import Optional import urwid @@ -75,7 +74,7 @@ def goto_end_of_line(text): class ExtendedEdit(urwid.Edit): """A text editing widget supporting some more editing commands""" - def keypress(self, size: tuple[int], key: Optional[str]) -> Optional[str]: + def keypress(self, size: tuple[int], key: str | None) -> str | None: if key == 'ctrl w': self._delete_word() elif key == 'ctrl u': @@ -560,7 +559,7 @@ def clear(self) -> None: """clear the alarm list""" self.pile.contents.clear() - def add_alarm(self, button, timedelta: Optional[dt.timedelta] = None): + def add_alarm(self, button, timedelta: dt.timedelta | None = None): if timedelta is None: timedelta = dt.timedelta(0) self.pile.contents.insert( diff --git a/khal/utils.py b/khal/utils.py index 4e7d4e70f..da948c3f3 100644 --- a/khal/utils.py +++ b/khal/utils.py @@ -30,7 +30,6 @@ from calendar import month_abbr, timegm from collections.abc import Iterator from textwrap import wrap -from typing import Optional import icalendar import pytz @@ -77,7 +76,7 @@ def find_last_sgr(string: str) -> tuple[int, int, str]: return last.start(), last.end(), last.group(0) -def find_unmatched_sgr(string: str) -> Optional[str]: +def find_unmatched_sgr(string: str) -> str | None: reset_pos, _, _ = find_last_reset(string) sgr_pos, _, sgr = find_last_sgr(string) if sgr_pos > reset_pos: diff --git a/pyproject.toml b/pyproject.toml index 7e7da77ff..989e5fce4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ classifiers = [ "Operating System :: POSIX", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -24,7 +23,7 @@ classifiers = [ "Topic :: Communications", "Topic :: Utilities", ] -requires-python = ">=3.8,<3.15" +requires-python = ">=3.10,<3.15" dependencies = [ "click>=3.2", "click_log>=0.2.0", @@ -46,7 +45,6 @@ test = [ "hypothesis", "packaging", "vdirsyncer", - "importlib-metadata; python_version <= '3.9'", # importlib.metadata is in stdlib since 3.10 ] docs = [ "sphinx!=1.6.1", @@ -77,7 +75,6 @@ version_file = "khal/version.py" [tool.ruff] line-length = 100 -target-version = "py39" [tool.ruff.lint] extend-select = [ diff --git a/tox.ini b/tox.ini index a71647ace..0216ba11f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = {py39,py310,py311,py312,py313,py314}-tests,py39-tests-{pytz2018.7,pytz_latest},vermin +envlist = {py310,py311,py312,py313,py314}-tests,py312-tests-{pytz2018.7,pytz_latest},vermin skip_missing_interpreters = True [testenv] @@ -10,6 +10,7 @@ passenv = extras = test +deps = pytz20187: pytz==2018.7 pytz_latest: pytz commands = @@ -17,7 +18,6 @@ commands = [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 @@ -34,5 +34,5 @@ commands = [testenv:vermin] deps = vermin commands = - vermin -t=3.9- --eval-annotations --backport enum --backport importlib --backport typing --no-parse-comments --violations . + vermin -t=3.10- --eval-annotations --backport enum --backport importlib --backport typing --no-parse-comments --violations . skip_install = true