Skip to content

Commit 0dea96e

Browse files
committed
Merge PR 83: feat: list --json outputs jCal (RFC 7265)
#83 list --json outputs the selection as jCal (RFC 7265), the standard JSON form of iCalendar, for scripts and agents. Requires icalendar 7.0.0, and --ics no longer repeats each timezone. Also restricts the hatch-vcs version lookup to v[0-9]* release tags. Commits: - 3ed3991 feat: list --json outputs jCal (RFC 7265) - 34d4768 style: sort imports in the jCal tests - beceb09 build: only take versions from v[0-9]* tags Fixes #82 Assisted-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Reviewed-by: Tobias Brox <tobias@redpill-linpro.com>
2 parents ae30c8d + beceb09 commit 0dea96e

7 files changed

Lines changed: 115 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and I do try to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.h
77

88
### Added
99

10+
* `list --json` prints the selection as jCal (RFC 7265), the standard JSON representation of iCalendar. https://github.com/pycalendar/plann/issues/82
1011
* `plann freebusy-sync`: keeps a copy of the busy time from one set of calendars in another calendar, holding only the time and a fixed summary. Copies are moved and deleted as the source changes, so it can run from a timer in both directions between a work and a private calendar. See the user guide.
1112

1213
### Deprecated
@@ -18,12 +19,13 @@ and I do try to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.h
1819
* Completing a task (`edit --complete`, `complete` in the interactive and multi-edit modes, `completed!` in `set-task-attribs`) now also completes its pending child tasks, recursively, and prints a warning for each. A child has to be done before the parent can be completed.
1920
* `completed!` in `set-task-attribs` moves a recurring task on to its next recurrence, like `edit --complete` does, instead of completing the whole series.
2021
* Shell tab completion scripts (bash, zsh, fish) now ship with the package. They work without shell configuration for system-wide installs (and for bash user/venv installs with bash-completion 2.12+); see the README for other setups. The `make install-completion` targets are gone.
21-
* icalendar 6.2.0 or newer is required.
22+
* icalendar 7.0.0 or newer is required.
2223
* caldav 3.6 or newer is required. The config file is now read as caldav 3.6 reads it, so it may also use `kind: caldav` sections with plain keys, `password_command` (a program printing the password) and `password_keyring`.
2324
* The config file is found on the search path shared with the caldav library and other tools: `~/.config/pycal/calendar.yaml` first, then caldav's paths, `~/.config/calendar.conf` and `/etc`, or the file `CALENDARING_CONFIG_FILE` / `CALDAV_CONFIG_FILE` names. Before, plann read `~/.config/calendar.conf` only, so with several of these files a different one may be used now. `plann configure` creates `~/.config/pycal/calendar.yaml` when there is none, or when the file it found cannot be written (one under `/etc`), and makes the file readable by its owner only.
2425

2526
### Fixed
2627

28+
* `list --ics` repeated the VTIMEZONE of every listed object, instead of giving each timezone once.
2729
* `interactive set-task-attribs` (also run by `interactive manage-tasks`): a task marked `completed!` at one prompt was asked about again at the next one, and answering `completed!` there crashed on the already-completed task. Regression in v1.2.0.
2830
* `list-categories` and timewarrior time tracking crashed on a task with more than one CATEGORIES line. https://github.com/pycalendar/plann/issues/71
2931
* `{CATEGORIES}` in a `list --template` printed the internal `vCategory([...])` representation instead of a comma-separated list.

‎USER_GUIDE.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,13 @@ Some calendar server implementations require `--todo` or `--event` to always be
109109

110110
### Listing objects
111111

112-
Events can either be output as ics, or through a template.
112+
Events can either be output as ics (`list --ics`), as json (`list --json`), or through a template.
113+
114+
The json output is jCal, the standard JSON representation of iCalendar (RFC 7265). It holds everything the ics output holds, and is easier for scripts (or AI agents) to work on than template output:
115+
116+
```
117+
plann select --event --start=today --end=+7d list --json
118+
```
113119

114120
The templating engine is built on top of the python `string.format()`. To learn the basics of `string.format()`, w3schools has some nice interactive thing on the web, https://www.w3schools.com/python/ref_string_format.asp
115121

‎plann/cli.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,16 +405,19 @@ def list_categories(ctx):
405405

406406
@select.command()
407407
@click.option('--ics/--no-ics', default=False, help="Output in ics format")
408+
@click.option('--json/--no-json', 'json_', default=False, help="Output in jCal (RFC 7265) JSON format")
408409
@click.option('--template', default="{DTSTART:?{DUE:?(date missing)?}?%F %H:%M:%S %Z}: {SUMMARY:?{DESCRIPTION:?(no summary given)?}?}")
409410
@click.option('--separator', default="\n", help="String to separate the listed items with (defaults to a newline)")
410411
@click.option('--top-down/--flat-list', help="Check relations and list the relations in a hierarchical way")
411412
@click.option('--bottom-up/--flat-list', help="List parents (dependencies) in a hierarchical way (cannot be combined with top-down)")
412413
@click.pass_context
413-
def list(ctx, ics, template, separator="\n", top_down=False, bottom_up=False):
414+
def list(ctx, ics, json_, template, separator="\n", top_down=False, bottom_up=False):
414415
"""
415416
Print out a list of tasks/events/journals
416417
"""
417-
return _list(ctx.obj['objs'], ics, template, top_down=top_down, bottom_up=bottom_up, separator=separator)
418+
if ics and json_:
419+
raise click.UsageError("--ics and --json cannot be combined")
420+
return _list(ctx.obj['objs'], ics, as_json=json_, template=template, top_down=top_down, bottom_up=bottom_up, separator=separator)
418421

419422

420423
@select.command()

‎plann/lib.py‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -648,7 +648,7 @@ def _set_something(obj, arg, value):
648648
## TODO: should be rewritten a bit, we should have a create_list method that does not call on click.echo directly
649649
## let the caller decide if click is to be used or not.
650650
## Use the yield method to avoid having to generate the full list prior to printing to screen
651-
def _list(objs, ics=False, template="{DTSTART:?{DUE:?(date missing)?}?%F %H:%M:%S %Z}: {SUMMARY:?{DESCRIPTION:?(no summary given)?}?}", top_down=False, bottom_up=False, indent=0, echo=True, uids=None, filter=lambda obj: True, separator="\n", cache=None):
651+
def _list(objs, ics=False, as_json=False, template="{DTSTART:?{DUE:?(date missing)?}?%F %H:%M:%S %Z}: {SUMMARY:?{DESCRIPTION:?(no summary given)?}?}", top_down=False, bottom_up=False, indent=0, echo=True, uids=None, filter=lambda obj: True, separator="\n", cache=None):
652652
"""
653653
Actual implementation of list
654654
@@ -661,14 +661,24 @@ def _list(objs, ics=False, template="{DTSTART:?{DUE:?(date missing)?}?%F %H:%M:%
661661
## task is not re-fetched from the server for every edge it touches (E3)
662662
if cache is None and (top_down or bottom_up):
663663
cache = _RelativeCache()
664-
if ics:
664+
if ics or as_json:
665665
accepted = [obj for obj in objs if filter(obj)]
666-
if not accepted:
666+
## a script parsing the json should get an empty calendar, not nothing
667+
if not accepted and not as_json:
667668
return
668-
icalendar = accepted[0].icalendar_instance
669-
for obj in accepted[1:]:
670-
icalendar.subcomponents.extend(obj.icalendar_instance.subcomponents)
671-
click.echo(icalendar.to_ical())
669+
## a fresh calendar, the objects' (cached) instances are not to be touched
670+
cal = icalendar.Calendar()
671+
cal.add('VERSION', '2.0')
672+
cal.add('PRODID', '-//plann//list//EN')
673+
tzids = set()
674+
for obj in accepted:
675+
for comp in obj.icalendar_instance.subcomponents:
676+
if comp.name == 'VTIMEZONE':
677+
if comp['TZID'] in tzids:
678+
continue
679+
tzids.add(comp['TZID'])
680+
cal.add_component(comp)
681+
click.echo(cal.to_json() if as_json else cal.to_ical())
672682
return
673683
if isinstance(template, str):
674684
template=Template(template)

‎pyproject.toml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ dependencies = [
3131
"sortedcontainers",
3232
"dateparser>=1.2",
3333
"python-dateutil",
34-
"icalendar>=6.2.0",
34+
"icalendar>=7.0.0",
3535
"icalendar_searcher",
3636
]
3737

@@ -64,6 +64,10 @@ build-backend = "hatchling.build"
6464

6565
[tool.hatch.version]
6666
source = "vcs"
67+
# Only v<digit>* tags are releases. The default describe takes the nearest
68+
# tag containing a digit, so a date-stamped local tag (a pre-rewrite backup,
69+
# say) could become the version and break the build.
70+
raw-options = { scm = { git = { describe_command = "git describe --dirty --tags --long --abbrev=40 --match v[0-9]*" } } }
6771

6872
[tool.hatch.build.hooks.vcs]
6973
version-file = "plann/_version.py"

‎tests/test_lib.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,3 +715,68 @@ def relships(obj, reltype_wanted=None, cache=None):
715715
_complete(parent, err_callback=warnings.append)
716716
assert parent.icalendar_component['STATUS'] == 'COMPLETED'
717717
assert warnings == []
718+
719+
720+
def test_list_json():
721+
"""`list --json` dumps the selection as one jCal (RFC 7265) calendar,
722+
like `--ics` does as iCalendar.
723+
https://github.com/pycalendar/plann/issues/82"""
724+
import json
725+
726+
from plann.lib import _list
727+
objs = [_todo_with_rels('a', []), _todo_with_rels('b', [])]
728+
with patch('plann.lib.click.echo') as echo:
729+
_list(objs, as_json=True)
730+
jcal = json.loads(echo.call_args.args[0])
731+
assert jcal[0] == 'vcalendar'
732+
uids = [prop[3] for comp in jcal[2] for prop in comp[1] if prop[0] == 'uid']
733+
assert uids == ['a', 'b']
734+
735+
736+
def test_list_json_empty():
737+
"""An empty selection still gives valid json."""
738+
import json
739+
740+
from plann.lib import _list
741+
with patch('plann.lib.click.echo') as echo:
742+
_list([], as_json=True)
743+
jcal = json.loads(echo.call_args.args[0])
744+
assert jcal[0] == 'vcalendar'
745+
assert {prop[0] for prop in jcal[1]} >= {'version', 'prodid'}
746+
assert jcal[2] == []
747+
748+
749+
def _event_in_oslo(uid):
750+
from caldav import Event
751+
obj = Event()
752+
obj.data = (
753+
"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Example Corp.//CalDAV Client//EN\n"
754+
"BEGIN:VTIMEZONE\nTZID:Europe/Oslo\n"
755+
"BEGIN:STANDARD\nDTSTART:19701025T030000\nTZOFFSETFROM:+0200\nTZOFFSETTO:+0100\n"
756+
"RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\nEND:STANDARD\n"
757+
"END:VTIMEZONE\n"
758+
f"BEGIN:VEVENT\nUID:{uid}\nDTSTART;TZID=Europe/Oslo:20261021T100000\n"
759+
f"SUMMARY:event {uid}\nEND:VEVENT\nEND:VCALENDAR"
760+
)
761+
return obj
762+
763+
764+
def test_list_ics_does_not_touch_the_objects():
765+
"""Merging the selection into one calendar must not modify the
766+
(cached) icalendar instance of the first object."""
767+
from plann.lib import _list
768+
objs = [_event_in_oslo('a'), _event_in_oslo('b')]
769+
for as_json in (False, True):
770+
with patch('plann.lib.click.echo'):
771+
_list(objs, ics=not as_json, as_json=as_json)
772+
assert len(objs[0].icalendar_instance.subcomponents) == 2
773+
774+
775+
def test_list_json_one_vtimezone_per_tzid():
776+
import json
777+
778+
from plann.lib import _list
779+
with patch('plann.lib.click.echo') as echo:
780+
_list([_event_in_oslo('a'), _event_in_oslo('b')], as_json=True)
781+
names = [comp[0] for comp in json.loads(echo.call_args.args[0])[2]]
782+
assert names == ['vtimezone', 'vevent', 'vevent']

‎tests/test_plann_cli.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,3 +712,16 @@ def test_new_config_file_is_private(tmp_path, monkeypatch):
712712
os.umask(old)
713713
assert stat.S_IMODE(path.stat().st_mode) == 0o600
714714
assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700
715+
716+
717+
def test_list_json_and_ics_conflict():
718+
res = CliRunner().invoke(cli_mod.list, ['--ics', '--json'], obj={'objs': [_make_todo('a')]})
719+
assert res.exit_code != 0
720+
assert 'cannot be combined' in res.output
721+
722+
723+
def test_list_json():
724+
import json
725+
res = CliRunner().invoke(cli_mod.list, ['--json'], obj={'objs': [_make_todo('a')]})
726+
assert res.exit_code == 0, res.output
727+
assert json.loads(res.output)[0] == 'vcalendar'

0 commit comments

Comments
 (0)