ics-query 0.1.0a0__py3-none-any.whl → 0.1.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. ics_query/__init__.py +18 -2
  2. ics_query/__main__.py +15 -0
  3. ics_query/_version.py +2 -2
  4. ics_query/cli.py +620 -18
  5. ics_query/parse.py +78 -4
  6. ics_query/query.py +78 -0
  7. ics_query/tests/conftest.py +68 -20
  8. ics_query/tests/runs/all --tz Singapore one-event.ics -.run +9 -0
  9. ics_query/tests/runs/all three-events.ics -.run +33 -0
  10. ics_query/tests/runs/at 2019-03-04 multiple-calendars.ics -.run +20 -0
  11. ics_query/tests/runs/at 2019-03-04 one-event-twice.ics -.run +18 -0
  12. ics_query/tests/runs/at 2019-03-07 multiple-calendars.ics -.run +11 -0
  13. ics_query/tests/runs/at 2024-08-20 Berlin-Los-Angeles.ics -.run +23 -0
  14. ics_query/tests/runs/between 20240823 4d recurring-work-events.ics -.run +24 -0
  15. ics_query/tests/runs/calendars/Berlin-Los-Angeles.ics +381 -0
  16. ics_query/tests/runs/calendars/empty-calendar.ics +7 -0
  17. ics_query/tests/runs/calendars/empty-file.ics +0 -0
  18. ics_query/tests/runs/calendars/multiple-calendars.ics +71 -0
  19. ics_query/tests/runs/calendars/one-event-twice.ics +68 -0
  20. ics_query/tests/runs/calendars/one-event-without-timezone.ics +14 -0
  21. ics_query/tests/runs/calendars/recurring-work-events.ics +223 -0
  22. ics_query/tests/runs/calendars/simple-journal.ics +15 -0
  23. ics_query/tests/runs/calendars/simple-todo.ics +15 -0
  24. ics_query/tests/runs/calendars/three-events.ics +37 -0
  25. ics_query/tests/runs/first -c VJOURNAL -c VEVENT one-event.ics -.run +9 -0
  26. ics_query/tests/runs/first -c VJOURNAL one-event.ics -.run +0 -0
  27. ics_query/tests/runs/first -c VJOURNAL simple-journal.ics -.run +12 -0
  28. ics_query/tests/runs/first -c VTODO -c VJOURNAL simple-todo.ics -.run +10 -0
  29. ics_query/tests/runs/first -c VTODO simple-todo.ics -.run +10 -0
  30. ics_query/tests/runs/first empty-calendar.ics -.run +0 -0
  31. ics_query/tests/runs/first empty-file.ics -.run +0 -0
  32. ics_query/tests/runs/first recurring-work-events.ics -.run +12 -0
  33. ics_query/tests/test_command_line.py +62 -0
  34. ics_query/tests/test_parse_date.py +81 -0
  35. ics_query/tests/test_parse_timedelta.py +40 -0
  36. ics_query/version.py +36 -3
  37. {ics_query-0.1.0a0.dist-info → ics_query-0.1.dev1.dist-info}/METADATA +366 -43
  38. ics_query-0.1.dev1.dist-info/RECORD +44 -0
  39. ics_query-0.1.0a0.dist-info/RECORD +0 -16
  40. {ics_query-0.1.0a0.dist-info → ics_query-0.1.dev1.dist-info}/WHEEL +0 -0
  41. {ics_query-0.1.0a0.dist-info → ics_query-0.1.dev1.dist-info}/entry_points.txt +0 -0
  42. {ics_query-0.1.0a0.dist-info → ics_query-0.1.dev1.dist-info}/licenses/LICENSE +0 -0
ics_query/parse.py CHANGED
@@ -1,13 +1,87 @@
1
+ # ics-query
2
+ # Copyright (C) 2024 Nicco Kunzmann
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
1
16
  """Functions for parsing the content."""
2
17
 
3
18
  from __future__ import annotations
4
19
 
5
- DateArgument = tuple[int]
20
+ import datetime
21
+ import re
22
+ from typing import Union
6
23
 
24
+ Date = tuple[int]
25
+ DateAndDelta = Union[Date, datetime.timedelta]
7
26
 
8
- def to_time(dt: str) -> DateArgument:
27
+
28
+ class InvalidTimeFormat(ValueError):
29
+ """The value provided does not yield a precise time."""
30
+
31
+
32
+ REGEX_TIME = re.compile(
33
+ r"^(?P<year>\d\d\d\d)"
34
+ r"(?P<month>-\d?\d|\d\d)?"
35
+ r"(?P<day>-\d?\d|\d\d)?"
36
+ r"(?P<hour>[ T]\d?\d|\d\d)?"
37
+ r"(?P<minute>:\d?\d|\d\d)?"
38
+ r"(?P<second>:\d?\d|\d\d)?"
39
+ r"$"
40
+ )
41
+
42
+ REGEX_TIMEDELTA = re.compile(
43
+ r"^\+?(?:(?P<days>\d+)d)?"
44
+ r"(?:(?P<hours>\d+)h)?"
45
+ r"(?:(?P<minutes>\d+)m)?"
46
+ r"(?:(?P<seconds>\d+)s)?"
47
+ r"$"
48
+ )
49
+
50
+
51
+ def to_time(dt: str) -> Date:
9
52
  """Parse the time and date."""
10
- return tuple(map(int, dt.split("-")))
53
+ parsed_dt = REGEX_TIME.match(dt)
54
+ if parsed_dt is None:
55
+ raise InvalidTimeFormat(dt)
56
+
57
+ def group(group_name: str) -> Date:
58
+ """Return a group's value."""
59
+ result = parsed_dt.group(group_name)
60
+ while result and result[0] not in "0123456789":
61
+ result = result[1:]
62
+ if result is None:
63
+ return ()
64
+ return (int(result),)
65
+
66
+ return (
67
+ group("year")
68
+ + group("month")
69
+ + group("day")
70
+ + group("hour")
71
+ + group("minute")
72
+ + group("second")
73
+ )
74
+
75
+
76
+ def to_time_and_delta(dt: str) -> DateAndDelta:
77
+ """Parse to a absolute time or timedelta."""
78
+ parsed_td = REGEX_TIMEDELTA.match(dt)
79
+ if parsed_td is None:
80
+ return to_time(dt)
81
+ kw = {k: int(v) for k, v in parsed_td.groupdict().items() if v is not None}
82
+ if not kw:
83
+ raise InvalidTimeFormat(dt)
84
+ return datetime.timedelta(**kw)
11
85
 
12
86
 
13
- __all__ = ["to_time", "DateArgument"]
87
+ __all__ = ["to_time", "Date", "to_time_and_delta", "DateAndDelta"]
ics_query/query.py ADDED
@@ -0,0 +1,78 @@
1
+ # ics-query
2
+ # Copyright (C) 2024 Nicco Kunzmann
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ """This is an adaptation of the CalendarQuery."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime
21
+ import zoneinfo
22
+ from typing import TYPE_CHECKING
23
+
24
+ import x_wr_timezone
25
+ from recurring_ical_events import CalendarQuery, Occurrence
26
+ from tzlocal import get_localzone
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Sequence
30
+
31
+ from icalendar import Calendar
32
+
33
+
34
+ class Query(CalendarQuery):
35
+ def __init__(self, calendar: Calendar, timezone: str, components: Sequence[str]):
36
+ """Create a new query."""
37
+ super().__init__(
38
+ x_wr_timezone.to_standard(calendar),
39
+ components=components,
40
+ skip_bad_series=True,
41
+ )
42
+ self.timezone = self.get_timezone(timezone)
43
+
44
+ def get_timezone(self, timezone: str) -> datetime.tzinfo | None:
45
+ """Return the local time tz."""
46
+ if timezone == "localtime":
47
+ return get_localzone()
48
+ return zoneinfo.ZoneInfo(timezone) if timezone else None
49
+
50
+ def with_timezone(self, dt: datetime.date | datetime.datetime):
51
+ """Add the timezone."""
52
+ if self.timezone is None:
53
+ return dt
54
+ if not isinstance(dt, datetime.datetime):
55
+ return datetime.datetime(
56
+ year=dt.year, month=dt.month, day=dt.day, tzinfo=self.timezone
57
+ )
58
+ if dt.tzinfo is None:
59
+ return dt.replace(tzinfo=self.timezone)
60
+ return dt.astimezone(self.timezone)
61
+
62
+ def _occurrences_between(
63
+ self,
64
+ start: datetime.date | datetime.datetime,
65
+ end: datetime.date | datetime.datetime,
66
+ ) -> list[Occurrence]:
67
+ """Override to adapt timezones."""
68
+ result = []
69
+ for occurrence in super()._occurrences_between(
70
+ self.with_timezone(start), self.with_timezone(end)
71
+ ):
72
+ occurrence.start = self.with_timezone(occurrence.start)
73
+ occurrence.end = self.with_timezone(occurrence.end)
74
+ result.append(occurrence)
75
+ return result
76
+
77
+
78
+ __all__ = ["Query"]
@@ -1,16 +1,31 @@
1
+ # ics-query
2
+ # Copyright (C) 2024 Nicco Kunzmann
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
1
16
  """Configure the tests."""
2
17
 
3
18
  from __future__ import annotations
4
19
 
5
20
  import subprocess
6
- from copy import deepcopy
7
21
  from pathlib import Path
8
- from typing import NamedTuple
22
+ from typing import Callable, NamedTuple
9
23
 
10
24
  import pytest
11
25
 
12
26
  HERE = Path(__file__).parent
13
27
  IO_DIRECTORY = HERE / "runs"
28
+ CALENDARS_DIRECTORY = IO_DIRECTORY / "calendars"
14
29
 
15
30
 
16
31
  class TestRun(NamedTuple):
@@ -34,45 +49,78 @@ class TestRun(NamedTuple):
34
49
  )
35
50
 
36
51
 
52
+ def get_binary_path(request: pytest.FixtureRequest) -> str:
53
+ """Return the path to the ics-query command."""
54
+ command: str = request.config.getoption("--binary")
55
+ if command == "ics-query":
56
+ # The default command can be found on the command line
57
+ return command
58
+ # we must set the path to be absolute
59
+ return Path(command).absolute()
60
+
61
+
62
+ def run_ics_query(*command, cwd=CALENDARS_DIRECTORY, binary: str) -> TestRun:
63
+ """Run ics-qeury with a command.
64
+
65
+ - cwd is the working directory
66
+ - binary is the path to the command
67
+ """
68
+ cmd = [binary, *command]
69
+ print(" ".join(map(str, cmd)))
70
+ completed_process = subprocess.run( # noqa: S603, RUF100
71
+ cmd, # noqa: S603, RUF100
72
+ capture_output=True,
73
+ timeout=10,
74
+ check=False,
75
+ cwd=cwd,
76
+ )
77
+ return TestRun.from_completed_process(completed_process)
78
+
79
+
37
80
  class IOTestCase(NamedTuple):
38
81
  """An example test case."""
39
82
 
40
83
  name: str
41
84
  command: list[str]
42
- cwd: Path
85
+ location: Path
43
86
  expected_output: str
87
+ binary: str
44
88
 
45
89
  @classmethod
46
- def from_path(cls, path: Path) -> IOTestCase:
90
+ def from_path(cls, path: Path, binary: str) -> IOTestCase:
47
91
  """Create a new testcase from the files."""
48
92
  expected_output = path.read_text(encoding="UTF-8").replace("\r\n", "\n")
49
- return cls(path.name, path.stem.split(), path.parent, expected_output)
93
+ return cls(path.name, path.stem.split(), path.parent, expected_output, binary)
50
94
 
51
95
  def run(self) -> TestRun:
52
96
  """Run this test case and return the result."""
53
- command = ["ics-query", *self.command]
54
- print(" ".join(command))
55
- completed_process = subprocess.run( # noqa: S603, RUF100
56
- command, # noqa: S603, RUF100
57
- capture_output=True,
58
- timeout=3,
59
- check=False,
60
- cwd=self.cwd / "calendars",
61
- )
62
- return TestRun.from_completed_process(completed_process)
97
+ return run_ics_query(*self.command, binary=self.binary)
63
98
 
64
99
 
65
- io_test_cases = [
66
- IOTestCase.from_path(test_case_path)
100
+ io_test_case_paths = [
101
+ test_case_path
67
102
  for test_case_path in IO_DIRECTORY.iterdir()
68
103
  if test_case_path.is_file()
69
104
  ]
70
105
 
71
106
 
72
- @pytest.fixture(params=io_test_cases)
73
- def io_testcase(request) -> IOTestCase:
107
+ @pytest.fixture(params=io_test_case_paths)
108
+ def io_testcase(request: pytest.FixtureRequest) -> IOTestCase:
74
109
  """Go though all the IO test cases."""
75
- return deepcopy(request.param)
110
+ path: Path = request.param
111
+ binary = get_binary_path(request)
112
+ return IOTestCase.from_path(path, binary)
113
+
114
+
115
+ @pytest.fixture
116
+ def run(request: pytest.FixtureRequest) -> Callable[..., TestRun]:
117
+ """Return a runner function."""
118
+
119
+ def run(*args, **kw):
120
+ kw["binary"] = get_binary_path(request)
121
+ return run_ics_query(*args, **kw)
122
+
123
+ return run
76
124
 
77
125
 
78
126
  __all__ = ["IOTestCase", "TestRun"]
@@ -0,0 +1,9 @@
1
+ BEGIN:VEVENT
2
+ SUMMARY:test1
3
+ DTSTART;TZID=Singapore:20190304T150000
4
+ DTEND;TZID=Singapore:20190304T153000
5
+ DTSTAMP:20190303T111937
6
+ UID:UYDQSG9TH4DE0WM3QFL2J
7
+ CREATED:20190303T111937
8
+ LAST-MODIFIED:20190303T111937
9
+ END:VEVENT
@@ -0,0 +1,33 @@
1
+ BEGIN:VEVENT
2
+ SUMMARY:test4
3
+ DTSTART;TZID=Europe/Berlin:20190304T000000
4
+ DTEND;TZID=Europe/Berlin:20190304T010000
5
+ DTSTAMP:20190303T111937
6
+ UID:UYDQSG9TH4DE0WM3QFL2J
7
+ CLASS:PUBLIC
8
+ CREATED:20190303T111937
9
+ LAST-MODIFIED:20190303T111937
10
+ STATUS:CONFIRMED
11
+ END:VEVENT
12
+ BEGIN:VEVENT
13
+ SUMMARY:test4
14
+ DTSTART;TZID=Europe/Berlin:20190307T000000
15
+ DTEND;TZID=Europe/Berlin:20190307T010000
16
+ DTSTAMP:20190303T111937
17
+ UID:UYDQSG9TH4DE0WM3QFL2J
18
+ CLASS:PUBLIC
19
+ CREATED:20190303T111937
20
+ LAST-MODIFIED:20190303T111937
21
+ STATUS:CONFIRMED
22
+ END:VEVENT
23
+ BEGIN:VEVENT
24
+ SUMMARY:test4
25
+ DTSTART;TZID=Europe/Berlin:20190310T000000
26
+ DTEND;TZID=Europe/Berlin:20190310T010000
27
+ DTSTAMP:20190303T111937
28
+ UID:UYDQSG9TH4DE0WM3QFL2J
29
+ CLASS:PUBLIC
30
+ CREATED:20190303T111937
31
+ LAST-MODIFIED:20190303T111937
32
+ STATUS:CONFIRMED
33
+ END:VEVENT
@@ -0,0 +1,20 @@
1
+ BEGIN:VEVENT
2
+ SUMMARY:test4
3
+ DTSTART;TZID=Europe/Berlin:20190304T000000
4
+ DTEND;TZID=Europe/Berlin:20190304T010000
5
+ DTSTAMP:20190303T111937
6
+ UID:UYDQSG9TH4DE0WM3QFL2J
7
+ CLASS:PUBLIC
8
+ CREATED:20190303T111937
9
+ LAST-MODIFIED:20190303T111937
10
+ STATUS:CONFIRMED
11
+ END:VEVENT
12
+ BEGIN:VEVENT
13
+ SUMMARY:test1
14
+ DTSTART;TZID=Europe/Berlin:20190304T080000
15
+ DTEND;TZID=Europe/Berlin:20190304T083000
16
+ DTSTAMP:20190303T111937
17
+ UID:UYDQSG9TH4DE0WM3QFL2J
18
+ CREATED:20190303T111937
19
+ LAST-MODIFIED:20190303T111937
20
+ END:VEVENT
@@ -0,0 +1,18 @@
1
+ BEGIN:VEVENT
2
+ SUMMARY:test1
3
+ DTSTART;TZID=Europe/Berlin:20190304T080000
4
+ DTEND;TZID=Europe/Berlin:20190304T083000
5
+ DTSTAMP:20190303T111937
6
+ UID:UYDQSG9TH4DE0WM3QFL2J
7
+ CREATED:20190303T111937
8
+ LAST-MODIFIED:20190303T111937
9
+ END:VEVENT
10
+ BEGIN:VEVENT
11
+ SUMMARY:test1
12
+ DTSTART;TZID=Europe/Berlin:20190304T080000
13
+ DTEND;TZID=Europe/Berlin:20190304T083000
14
+ DTSTAMP:20190303T111937
15
+ UID:UYDQSG9TH4DE0WM3QFL2J
16
+ CREATED:20190303T111937
17
+ LAST-MODIFIED:20190303T111937
18
+ END:VEVENT
@@ -0,0 +1,11 @@
1
+ BEGIN:VEVENT
2
+ SUMMARY:test4
3
+ DTSTART;TZID=Europe/Berlin:20190307T000000
4
+ DTEND;TZID=Europe/Berlin:20190307T010000
5
+ DTSTAMP:20190303T111937
6
+ UID:UYDQSG9TH4DE0WM3QFL2J
7
+ CLASS:PUBLIC
8
+ CREATED:20190303T111937
9
+ LAST-MODIFIED:20190303T111937
10
+ STATUS:CONFIRMED
11
+ END:VEVENT
@@ -0,0 +1,23 @@
1
+ BEGIN:VEVENT
2
+ SUMMARY:6:00-7:00 Europe/Berlin 20th August
3
+ DTSTART;TZID=Europe/Berlin:20240820T060000
4
+ DTEND;TZID=Europe/Berlin:20240820T070000
5
+ DTSTAMP:20240823T130444Z
6
+ UID:b27ea261-f23d-4e03-a7ac-f8cb0d00f07f
7
+ CREATED:20240823T120639Z
8
+ LAST-MODIFIED:20240823T130444Z
9
+ TRANSP:OPAQUE
10
+ X-MOZ-GENERATION:3
11
+ END:VEVENT
12
+ BEGIN:VEVENT
13
+ SUMMARY:6:00-7:00 Amerika/Los Angeles 20th August
14
+ DTSTART;TZID=America/Los_Angeles:20240820T060000
15
+ DTEND;TZID=America/Los_Angeles:20240820T070000
16
+ DTSTAMP:20240823T130711Z
17
+ UID:6d7ff7f3-4bc4-4d89-afa0-771bd690518a
18
+ SEQUENCE:1
19
+ CREATED:20240823T120639Z
20
+ LAST-MODIFIED:20240823T130711Z
21
+ TRANSP:OPAQUE
22
+ X-MOZ-GENERATION:4
23
+ END:VEVENT
@@ -0,0 +1,24 @@
1
+ BEGIN:VEVENT
2
+ SUMMARY:Work
3
+ DTSTART;TZID=Europe/Berlin:20240823T090000
4
+ DTEND;TZID=Europe/Berlin:20240823T170000
5
+ DTSTAMP:20240823T082915Z
6
+ UID:6b85b60c-eb1a-4338-9ece-33541b95bf17
7
+ SEQUENCE:1
8
+ CREATED:20240823T082829Z
9
+ LAST-MODIFIED:20240823T082915Z
10
+ TRANSP:OPAQUE
11
+ X-MOZ-GENERATION:2
12
+ END:VEVENT
13
+ BEGIN:VEVENT
14
+ SUMMARY:Work
15
+ DTSTART;TZID=Europe/Berlin:20240826T090000
16
+ DTEND;TZID=Europe/Berlin:20240826T170000
17
+ DTSTAMP:20240823T082915Z
18
+ UID:6b85b60c-eb1a-4338-9ece-33541b95bf17
19
+ SEQUENCE:1
20
+ CREATED:20240823T082829Z
21
+ LAST-MODIFIED:20240823T082915Z
22
+ TRANSP:OPAQUE
23
+ X-MOZ-GENERATION:2
24
+ END:VEVENT