├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── python.setup.cfg ├── python.setup.toml ├── requirements.txt ├── src ├── __init__.py ├── cli.py ├── config.py ├── output.py ├── taskcal.py └── taskcal_server.py ├── taskcal.yml └── tests ├── __init__.py ├── conftest.py ├── integration.py ├── test_cli.py ├── test_config.py ├── test_core.py ├── test_output.py └── test_server.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: unit-tests 4 | 5 | # Controls when the workflow will run 6 | on: # yamllint disable-line rule:truthy 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [master] 10 | pull_request: 11 | branches: [master] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: ~ 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v2 27 | 28 | - name: Setup Python 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: "3.x" 32 | 33 | - name: Cache APT Packages 34 | uses: awalsh128/cache-apt-pkgs-action@v1 35 | with: 36 | packages: cmake 37 | 38 | - name: Compile taskwarrior 39 | run: | 40 | cd /tmp 41 | git clone https://github.com/GothenburgBitFactory/taskwarrior 42 | cd taskwarrior 43 | cmake -DCMAKE_BUILD_TYPE=release -DENABLE_SYNC=OFF . 44 | make 45 | sudo make install 46 | 47 | - name: Print taskwarrior version 48 | run: task --version 49 | 50 | - name: Install python dependencies 51 | run: python -m pip install -r requirements.txt 52 | 53 | - name: Run tests 54 | run: coverage run --source=src -m pytest 55 | 56 | - name: Generate report 57 | run: coverage xml 58 | 59 | - name: Codecov 60 | uses: codecov/codecov-action@v2.1.0 61 | with: 62 | token: b62ba758-60a5-4c16-9cd9-f9fd7fc3560e 63 | fail_ci_if_error: true 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | coverage.xml 3 | *.ics 4 | .pytest_cache 5 | venv 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Taskcal 2 | 3 | A python script to export your pending TaskWarrior tasks into icalendar files (.ics). 4 | 5 | These can be imported to conventional calendaring applications. The script will 6 | create one ics file per TaskWarrior project, so that when you import the files to 7 | your calendar app, each project will have its own calendar. There is no support 8 | for the reverse (i.e. icalendar -> TaskWarrior) yet, although it is in the backlog. 9 | 10 | TaskWarrior attributes that get exported currently: 11 | 12 | - description 13 | - uuid 14 | - tags 15 | - priority 16 | - dependencies 17 | - status 18 | - due date 19 | - scheduled date 20 | - modified date 21 | - end date 22 | 23 | ## Installation 24 | 25 | There is currently no pip package for taskcal. 26 | 27 | Run: 28 | 29 | ``` 30 | pip install -r requirements.txt 31 | ``` 32 | 33 | and download the script file ([src/taskcal.py](src/taskcal.py)). 34 | -------------------------------------------------------------------------------- /python.setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | select = A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,B9 4 | 5 | ignore = E501,W503,E402,E203,B404,B101 6 | # E501 Line too long (pycodestyle) 7 | # W503 Line break occurred before a binary operator (pycodestyle) 8 | # E402 Module level import not at top of file (pycodestyle) 9 | # E203 Whitespace before ':' (pycodestyle) 10 | # B404 bandit: sec implications of subprocess module 11 | 12 | [isort] 13 | # https://black.readthedocs.io/en/stable/compatible_configs.html#isort 14 | multi_line_output = 3 15 | include_trailing_comma = True 16 | force_grid_wrap = 0 17 | use_parentheses = True 18 | ensure_newline_before_comments = True 19 | line_length = 88 20 | forced_separate = true 21 | lines_between_sections = 1 22 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 23 | known_first_party = taskcal 24 | 25 | [mypy] 26 | namespace_packages = True 27 | cache_dir = /tmp/mypy 28 | disallow_untyped_defs = True 29 | show_traceback = True 30 | strict_equality = True 31 | local_partial_types = True 32 | explicit_package_bases = True 33 | 34 | # Do not complain about imported modules that do not have type annotations 35 | ignore_missing_imports = True 36 | 37 | [pydocstyle] 38 | -------------------------------------------------------------------------------- /python.setup.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tasklib 2 | icalendar 3 | flask 4 | gevent 5 | pyyaml 6 | caldav 7 | pytest-mock 8 | 9 | # separate these 10 | pytest 11 | pytest-cov 12 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nkakouros-original/taskcal/9e8b2e4ab1344aab15a011a1cfb68a256fc4761d/src/__init__.py -------------------------------------------------------------------------------- /src/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | if __name__ == "__main__": 4 | 5 | import argparse 6 | import os 7 | 8 | from config import load_config 9 | from output import resolve_output 10 | from taskcal import Taskcal 11 | 12 | parser = argparse.ArgumentParser() 13 | parser.add_argument( 14 | "-c", 15 | "--config", 16 | type=str, 17 | help="path to the configuration file", 18 | ) 19 | args = parser.parse_args() 20 | 21 | config = load_config(args.config) 22 | 23 | tw_data_location = os.getenv("TASKDATA", "~/.task") 24 | 25 | tc = Taskcal(tw_data_dir=tw_data_location) 26 | 27 | for output_config in config["outputs"]: 28 | output = resolve_output(output_config["type"])(output_config) 29 | output.sync(tc.calendars) 30 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | import yaml 6 | 7 | 8 | def load_config(config_file=None): 9 | config = {} 10 | 11 | taskcalrc = os.path.expanduser( 12 | config_file 13 | or os.path.isfile("./taskcal.yml") 14 | and "taskcal.yml" 15 | or os.getenv("TASKCALRC") 16 | or os.getenv("XDG_CONFIG_HOME", "~/.config") + "/taskcal/config.yml" 17 | ) 18 | 19 | taskcalrc = os.path.realpath(taskcalrc) 20 | 21 | if os.path.isfile(taskcalrc): 22 | with open(taskcalrc, "r") as f: 23 | config.update(yaml.safe_load(f)) 24 | 25 | return config 26 | -------------------------------------------------------------------------------- /src/output.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # TODO validate config options for outputs 4 | 5 | import os 6 | from abc import ABC, abstractmethod 7 | 8 | import caldav 9 | from icalendar import Calendar 10 | 11 | from taskcal import icalendar_defaults 12 | 13 | 14 | def resolve_output(type): 15 | if type == "ics": 16 | return ICSOutput 17 | if type == "caldav": 18 | return CaldavOutput 19 | 20 | raise ValueError(f"invalid output type: {type}") 21 | 22 | 23 | class Output(ABC): 24 | def __init__(self, config): 25 | for k, v in config.items(): 26 | setattr(self, k, v) 27 | 28 | @abstractmethod 29 | def sync(self, calendars): 30 | if not getattr(self, "enabled", True): 31 | return False 32 | 33 | return True 34 | 35 | 36 | class ICSOutput(Output): 37 | def __init__(self, config): 38 | self.dir = os.path.realpath(config["dir"]) 39 | 40 | def sync(self, calendars): 41 | os.makedirs(self.dir, exist_ok=True) 42 | 43 | for calendar, content in calendars.items(): 44 | with open(f"{self.dir}/{calendar}.ics", "wb") as f: 45 | f.write(content.to_ical()) 46 | 47 | 48 | class CaldavOutput(Output): 49 | def sync(self, calendars): 50 | dav = caldav.DAVClient( 51 | self.host, 52 | username=self.username, 53 | password=self.password, 54 | ).principal() 55 | 56 | dav_cals = dav.calendars() 57 | dav_cal_names = [x.name for x in dav_cals] 58 | 59 | for calname, calendar in calendars.items(): 60 | try: 61 | index = dav_cal_names.index( 62 | calendar["NAME"] if "NAME" in calendar else calname 63 | ) 64 | davcal = dav_cals[index] 65 | except ValueError: 66 | davcal = dav.make_calendar(name=calname) 67 | 68 | for event in calendar.walk(name="VTODO"): 69 | temp_cal = Calendar(calendar) 70 | temp_cal.add_component(event) 71 | davcal.save_event(temp_cal.to_ical()) 72 | -------------------------------------------------------------------------------- /src/taskcal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # TODO 4 | # set custom prodid 5 | # apply tw filter in Taskcal's constructor already 6 | 7 | import os 8 | from collections import namedtuple 9 | from pathlib import Path 10 | from typing import DefaultDict, Dict 11 | 12 | from icalendar import Calendar, Todo 13 | from tasklib import Task, TaskWarrior 14 | from tasklib.filters import TaskWarriorFilter 15 | 16 | icalendar_defaults = { 17 | "version": "2.0", 18 | "prodid": "-//SabreDAV//SabreDAV//EN", 19 | "calscale": "gregorian", 20 | } 21 | 22 | # Taskwarrior attributes to icalendar properties 23 | Assoc = namedtuple("Assoc", ["attr", "prop"]) 24 | simple_associations = frozenset( 25 | [ 26 | Assoc("uuid", "uid"), 27 | Assoc("description", "summary"), 28 | Assoc("tags", "categories"), 29 | ] 30 | ) 31 | date_associations = frozenset( 32 | [ 33 | Assoc("modified", "dtstamp"), 34 | Assoc("end", "completed"), 35 | Assoc("due", "due"), 36 | Assoc("scheduled", "start"), 37 | ] 38 | ) 39 | direct_associations = simple_associations | date_associations 40 | 41 | other_associations = frozenset( 42 | [ 43 | Assoc("priority", "priority"), 44 | Assoc("depends", "related-to"), 45 | Assoc("status", "status"), 46 | ] 47 | ) 48 | 49 | priorities = {"H": 0, "M": 5, "L": 9} 50 | 51 | 52 | class Taskcal: 53 | def __init__( 54 | self, 55 | *, 56 | tw_rc: str = None, 57 | tw_data_dir: str = None, 58 | filter: str = "status.any:", 59 | ) -> None: 60 | 61 | # When tasklib runs taskwarrior commands, it overrides the data.location 62 | # settings if the user sets it to a custom value in the TaskWarrior 63 | # constructor. It also sets TASKRC to a custom value if the user sets 64 | # that too in the constructor. If only one of the two (or none of the 65 | # two) is set by the user, the taskwarrior command will do its own 66 | # resolution of the data location based on, among others, the env 67 | # variables TASKDATA and TASKRC. To be sure that when I use 68 | # `tw_data_dir` or `tw_rc` in this constructor I will always use the 69 | # intended path, I unset these env variables. 70 | os.environ.pop("TASKDATA", None) 71 | os.environ.pop("TASKRC", None) 72 | 73 | if tw_data_dir and tw_rc: 74 | raise TypeError( 75 | "only one of 'tw_data_dir' and 'tw_rc' must be given" 76 | ) 77 | elif tw_data_dir: 78 | if not Path(tw_data_dir).exists(): 79 | raise FileNotFoundError 80 | if not Path(tw_data_dir).is_dir(): 81 | raise NotADirectoryError(f"'{tw_data_dir}' is not a directory") 82 | self.tw = TaskWarrior(data_location=tw_data_dir) 83 | elif tw_rc: 84 | if not Path(tw_rc).exists(): 85 | raise FileNotFoundError 86 | if not Path(tw_rc).is_file(): 87 | raise FileNotFoundError(f"'{tw_rc}' is not a file") 88 | self.tw = TaskWarrior(taskrc_location=tw_rc) 89 | else: 90 | raise TypeError("no taskwarrior data dir or taskrc path given") 91 | 92 | self.filter = TaskWarriorFilter(self.tw) 93 | self.filter.add_filter(filter) 94 | 95 | @property 96 | def calendars(self) -> Dict[str, Calendar]: 97 | calendars: DefaultDict[str, Calendar] = DefaultDict( 98 | lambda: Calendar(icalendar_defaults) 99 | ) 100 | 101 | tasks = self.tw.filter_tasks(self.filter) 102 | 103 | if not tasks: 104 | # Initialize the default calendar 105 | calendars[""] 106 | 107 | for task in tasks: 108 | task.refresh() 109 | 110 | todo = Todo() 111 | 112 | for assoc in direct_associations: 113 | if task[assoc.attr]: 114 | todo.add(assoc.prop, task[assoc.attr]) 115 | 116 | if task["priority"]: 117 | todo.add("priority", priorities.get(task["priority"])) 118 | 119 | todo.add("status", self.tw_status_to_ics_status(task)) 120 | 121 | for dependency in task["depends"] or []: 122 | todo.add("related-to", dependency["uuid"]) 123 | 124 | calendars[task["project"] or ""].add_component(todo) 125 | 126 | for calname, calendar in calendars.items(): 127 | if calname == "": 128 | continue 129 | calendar.add("X-WR-CALNAME", calname) 130 | calendar.add("NAME", calname) 131 | 132 | return dict(calendars) 133 | 134 | @staticmethod 135 | def tw_status_to_ics_status(task: Task) -> str: 136 | if task.active: 137 | return "in-process" 138 | elif task.completed: 139 | return "completed" 140 | elif task.deleted: 141 | return "cancelled" 142 | else: # pending/waiting 143 | return "needs-action" 144 | -------------------------------------------------------------------------------- /src/taskcal_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | from flask import Flask, Response 6 | from gevent.pywsgi import WSGIServer 7 | 8 | from taskcal import Taskcal 9 | 10 | app = Flask(__name__) 11 | 12 | tw_data_location = os.getenv("TASKDATA", "~/.task") 13 | 14 | 15 | @app.route("/.ics") 16 | def stream_ics_files(calendar): 17 | def generate(calendar): 18 | try: 19 | tc = Taskcal(tw_data_dir=tw_data_location) 20 | except FileNotFoundError: 21 | return None 22 | 23 | try: 24 | icalendar = [ 25 | content 26 | for calname, content in tc.calendars.items() 27 | if calname == calendar 28 | ].pop() 29 | except IndexError: 30 | return "" 31 | 32 | return icalendar.to_ical() 33 | 34 | if (body := generate(calendar)) is None: 35 | response = Response( 36 | "nonexistent TaskWarrior db", status=500, mimetype="text/plain" 37 | ) 38 | elif not (body := generate(calendar)): 39 | response = Response( 40 | "no such calendar", status=404, mimetype="text/plain" 41 | ) 42 | else: 43 | response = Response(body, mimetype="text/calendar") 44 | response.headers.add( 45 | "Content-Disposition", f"attachment; filename={calendar}.ics" 46 | ) 47 | 48 | return response 49 | 50 | 51 | if __name__ == "__main__": # pragma: no cover 52 | http_server = WSGIServer(("", 5000), app) 53 | http_server.serve_forever() 54 | -------------------------------------------------------------------------------- /taskcal.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | outputs: 4 | - name: Default ICS file output 5 | type: ics 6 | dir: '.' 7 | enabled: true 8 | 9 | - name: WebDAV output 10 | type: caldav 11 | host: https://my-endpoint 12 | username: user 13 | password: 'app-password' 14 | enabled: true 15 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nkakouros-original/taskcal/9e8b2e4ab1344aab15a011a1cfb68a256fc4761d/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import pathlib 4 | import shutil 5 | 6 | import pytest 7 | import tasklib 8 | 9 | # TODO use tmp_dir or sth else 10 | tw_dir = ".tmp" 11 | 12 | 13 | @pytest.fixture 14 | def tw(): 15 | def _create_task(task: dict = None): 16 | tw = tasklib.TaskWarrior(data_location=tw_dir) 17 | if task: 18 | tw_task = tasklib.Task(tw, **task) 19 | tw_task.save() 20 | 21 | return tw_task 22 | 23 | yield _create_task 24 | 25 | shutil.rmtree(tw_dir) 26 | 27 | 28 | @pytest.fixture 29 | def project_root(): 30 | return pathlib.Path(__file__).resolve().parent.parent 31 | -------------------------------------------------------------------------------- /tests/integration.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | def test_ics_file_generation(tw): 5 | task1 = tw({"description": "task 1", "project": "project1"}) 6 | task2 = tw({"description": "task 2", "project": "project1"}) 7 | 8 | task3 = tw({"description": "task3", "project": "project1"}) 9 | task3["depends"] = set([task1, task2]) 10 | task3.save() 11 | 12 | task4 = tw({"description": "task 1"}) 13 | 14 | os.environ["TASKDATA"] = tw_dir 15 | 16 | output = ICSOutput() 17 | output.sync() 18 | 19 | with open("project1.ics") as f: 20 | ics = Calendar.from_ical(f.read()) 21 | 22 | todos = ics.walk(name="vtodo") 23 | compare(task1, todos[0]) 24 | compare(task2, todos[1]) 25 | compare(task3, todos[2]) 26 | 27 | with open(".ics") as f: 28 | ics = Calendar.from_ical(f.read()) 29 | 30 | compare(task4, ics.walk(name="vtodo")[0]) 31 | 32 | 33 | def test_output_folder_option(tw): 34 | task = tw({"description": "task 1", "project": "project1"}) 35 | 36 | import runpy 37 | import sys 38 | 39 | out_folder = ".tmp" 40 | 41 | os.environ["TASKDATA"] = tw_dir 42 | old_argv = sys.argv 43 | sys.argv = [old_argv[0], "--output-folder", out_folder] 44 | runpy.run_module(Taskcal.__module__, run_name="__main__") 45 | sys.argv = old_argv 46 | 47 | with open(f"{out_folder}/project1.ics") as f: 48 | ics = Calendar.from_ical(f.read()) 49 | 50 | todos = ics.walk(name="vtodo") 51 | compare(task, todos[0]) 52 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | 5 | sys.path.insert(0, "./src/") 6 | import cli 7 | import output 8 | 9 | 10 | def test_cli_run(mocker): 11 | import runpy 12 | import sys 13 | 14 | mocker.patch( 15 | "config.load_config", 16 | return_value={"outputs": [{"type": "output_type"}]}, 17 | ) 18 | 19 | taskcal_mock = mocker.Mock(name="taskcal", calendars=["cal1", "cal2"]) 20 | mocker.patch("taskcal.Taskcal", return_value=taskcal_mock) 21 | 22 | sync_mock = mocker.Mock(name="sync") 23 | output_function_mock = mocker.Mock( 24 | name="output_function", return_value=sync_mock 25 | ) 26 | mocker.patch("output.resolve_output", return_value=output_function_mock) 27 | 28 | mocker.seal(output) 29 | 30 | old_argv = sys.argv 31 | sys.argv = ["taskcal"] 32 | runpy.run_module("cli", run_name="__main__") 33 | sys.argv = old_argv 34 | 35 | assert sync_mock.sync.call_args_list[0].args[0] == taskcal_mock.calendars 36 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import shutil 5 | import sys 6 | from pathlib import Path 7 | 8 | import yaml 9 | 10 | sys.path.insert(0, "./src/") 11 | from config import load_config 12 | 13 | os.environ["TASKCALRC"] = os.path.realpath("./taskcal.yml") 14 | os.environ["XDG_CONFIG_HOME"] = "/tmp" 15 | 16 | 17 | def test_nonexistent_config(monkeypatch, tmp_path): 18 | monkeypatch.setenv("TASKCALRC", str(tmp_path / "not_a_file")) 19 | monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "not_a_dir")) 20 | 21 | monkeypatch.chdir(tmp_path) 22 | 23 | config = load_config() 24 | 25 | assert config == {} 26 | 27 | 28 | def test_loading_config_via_arg(monkeypatch, tmp_path, project_root): 29 | monkeypatch.setenv("TASKCALRC", str(tmp_path / "not_a_file")) 30 | monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "not_a_dir")) 31 | 32 | source_config = project_root / "taskcal.yml" 33 | 34 | monkeypatch.chdir(tmp_path) 35 | 36 | config = load_config(source_config) 37 | 38 | with open(source_config) as f: 39 | config2 = yaml.safe_load(f) 40 | 41 | assert config == config2 42 | 43 | 44 | def test_loading_default_config(monkeypatch, tmp_path, project_root): 45 | monkeypatch.setenv("TASKCALRC", str(tmp_path / "not_a_file")) 46 | monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "not_a_dir")) 47 | 48 | source_config = project_root / "taskcal.yml" 49 | 50 | shutil.copy(source_config, tmp_path) 51 | 52 | monkeypatch.chdir(tmp_path) 53 | 54 | config = load_config() 55 | 56 | with open(source_config) as f: 57 | config2 = yaml.safe_load(f) 58 | 59 | assert config == config2 60 | 61 | 62 | def test_loading_config_via_env_var( 63 | monkeypatch, tmp_path_factory, project_root 64 | ): 65 | 66 | working_dir = tmp_path_factory.mktemp("working_dir") 67 | config_dir = tmp_path_factory.mktemp("config_dir") 68 | 69 | monkeypatch.setenv("TASKCALRC", str(config_dir / "taskcal.yml")) 70 | monkeypatch.setenv("XDG_CONFIG_HOME", str(working_dir / "not_a_dir")) 71 | 72 | source_config = project_root / "taskcal.yml" 73 | 74 | shutil.copy(source_config, config_dir) 75 | 76 | monkeypatch.chdir(working_dir) 77 | 78 | config = load_config() 79 | 80 | with open(source_config) as f: 81 | config2 = yaml.safe_load(f) 82 | 83 | assert config == config2 84 | 85 | 86 | def test_loading_config_via_xdg_config( 87 | monkeypatch, tmp_path_factory, project_root 88 | ): 89 | working_dir = tmp_path_factory.mktemp("working_dir") 90 | config_dir = tmp_path_factory.mktemp("config_dir") 91 | 92 | monkeypatch.delenv("TASKCALRC") 93 | monkeypatch.setenv("XDG_CONFIG_HOME", str(config_dir)) 94 | 95 | config_dir.joinpath("taskcal").mkdir() 96 | 97 | source_config = project_root / "taskcal.yml" 98 | 99 | shutil.copy(source_config, config_dir / "taskcal/config.yml") 100 | 101 | monkeypatch.chdir(working_dir) 102 | 103 | config = load_config() 104 | 105 | with open(source_config) as f: 106 | config2 = yaml.safe_load(f) 107 | 108 | assert config == config2 109 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import datetime 4 | import os 5 | import sys 6 | 7 | import pytest 8 | from icalendar import Calendar 9 | 10 | from .conftest import tw_dir 11 | 12 | sys.path.insert(0, "./src/") 13 | from taskcal import Taskcal, date_associations, priorities, simple_associations 14 | 15 | 16 | def compare(task, todo): 17 | specially_handled = ["categories"] 18 | 19 | message = "Todo:{prop} == TaskWarrior:{attr}" 20 | for assoc in [ 21 | assoc 22 | for assoc in simple_associations 23 | if assoc.attr in task and assoc.prop not in specially_handled 24 | ]: 25 | assert todo[assoc.prop] == task[assoc.attr], message.format( 26 | attr=assoc.attr, prop=assoc.prop 27 | ) 28 | 29 | for assoc in [assoc for assoc in date_associations if assoc.attr in task]: 30 | assert todo[assoc.prop].dt == task[assoc.attr], message.format( 31 | attr=assoc.attr, prop=assoc.prop 32 | ) 33 | 34 | if task["tags"]: 35 | assert "categories" in todo 36 | assert set(todo["categories"].cats) == task["tags"], message.format( 37 | attr="tags", prop="categories" 38 | ) 39 | 40 | if task["priority"]: 41 | assert todo["priority"] == priorities[task["priority"]] 42 | 43 | 44 | def test_failed_initialization(): 45 | with pytest.raises(TypeError) as exception: 46 | Taskcal() 47 | assert ( 48 | str(exception.value) == "no taskwarrior data dir or taskrc path given" 49 | ) 50 | 51 | with pytest.raises(FileNotFoundError): 52 | Taskcal(tw_data_dir="/not-existing") 53 | with pytest.raises(FileNotFoundError): 54 | Taskcal(tw_rc="/not-existing") 55 | 56 | with pytest.raises(NotADirectoryError) as exception: 57 | Taskcal(tw_data_dir=os.path.realpath(__file__)) 58 | assert ( 59 | str(exception.value) 60 | == f"'{os.path.realpath(__file__)}' is not a directory" 61 | ) 62 | 63 | with pytest.raises(FileNotFoundError) as exception: 64 | Taskcal(tw_rc=os.path.dirname(os.path.realpath(__file__))) 65 | assert ( 66 | str(exception.value) 67 | == f"'{os.path.dirname(os.path.realpath(__file__))}' is not a file" 68 | ) 69 | 70 | 71 | def test_successful_initialization(tw): 72 | tw() 73 | 74 | with pytest.raises(TypeError) as exception: 75 | Taskcal(tw_data_dir="path", tw_rc="path") 76 | assert ( 77 | str(exception.value) 78 | == "only one of 'tw_data_dir' and 'tw_rc' must be given" 79 | ) 80 | 81 | tc = Taskcal(tw_data_dir=tw_dir) 82 | assert tc.tw.config["data.location"] == tw_dir 83 | 84 | with open(tw_dir + "/taskrc", "w+") as f: 85 | f.write(f"data.location={tw_dir}/subfolder") 86 | tc = Taskcal(tw_rc=tw_dir + "/taskrc") 87 | assert tc.tw.config["data.location"] == tw_dir + "/subfolder" 88 | 89 | 90 | def test_empty_calendar_when_no_tasks(tw): 91 | tw() 92 | 93 | tc = Taskcal(tw_data_dir=tw_dir) 94 | 95 | assert len(tc.calendars) == 1 96 | assert "" in tc.calendars 97 | assert len(tc.calendars[""].subcomponents) == 0 98 | assert "X-WR-CALNAME" not in tc.calendars 99 | 100 | 101 | def test_task_with_simple_associations(tw): 102 | task = tw( 103 | {"description": "one task", "tags": ["tag1", "tag2"], "priority": "H"} 104 | ) 105 | 106 | tc = Taskcal(tw_data_dir=tw_dir) 107 | 108 | assert len(tc.calendars) == 1 109 | assert len(tc.calendars[""].subcomponents) == 1 110 | compare(task, tc.calendars[""].subcomponents[0]) 111 | 112 | 113 | def test_priority_association(tw): 114 | for twp, icalp in priorities.items(): 115 | tw({"description": "task", "priority": twp}) 116 | 117 | tc = Taskcal(tw_data_dir=tw_dir) 118 | 119 | tc.calendars[""].subcomponents[0]["priority"] == icalp 120 | 121 | 122 | def test_task_with_changing_status(tw): 123 | task = tw({"description": "task"}) 124 | 125 | tc = Taskcal(tw_data_dir=tw_dir) 126 | 127 | assert tc.calendars[""].subcomponents[0]["status"] == "needs-action" 128 | 129 | task["wait"] = datetime.datetime.now() + datetime.timedelta(5) 130 | assert tc.calendars[""].subcomponents[0]["status"] == "needs-action" 131 | 132 | task.start() 133 | assert tc.calendars[""].subcomponents[0]["status"] == "in-process" 134 | 135 | task.done() 136 | assert tc.calendars[""].subcomponents[0]["status"] == "completed" 137 | 138 | task.delete() 139 | assert tc.calendars[""].subcomponents[0]["status"] == "cancelled" 140 | 141 | 142 | def test_task_with_dependencies(tw): 143 | task1 = tw({"description": "task1"}) 144 | task2 = tw({"description": "task2"}) 145 | task3 = tw({"description": "task3"}) 146 | task3["depends"] = set([task1, task2]) 147 | task3.save() 148 | 149 | dependency_list = set([task1["uuid"], task2["uuid"]]) 150 | 151 | tc = Taskcal(tw_data_dir=tw_dir) 152 | 153 | assert len(tc.calendars[""].subcomponents) == 3 154 | assert len(tc.calendars[""].subcomponents[-1]["related-to"]) == 2 155 | assert ( 156 | set(tc.calendars[""].subcomponents[-1]["related-to"]) 157 | == dependency_list 158 | ) 159 | 160 | 161 | def test_task_with_project(tw): 162 | task = tw({"description": "task with project", "project": "project1"}) 163 | 164 | tc = Taskcal(tw_data_dir=tw_dir) 165 | 166 | assert len(tc.calendars) == 1 167 | assert "project1" in tc.calendars 168 | assert tc.calendars["project1"]["X-WR-CALNAME"] == "project1" 169 | assert len(tc.calendars["project1"].subcomponents) == 1 170 | 171 | 172 | def test_task_with_dates(tw): 173 | wait = datetime.datetime.now() + datetime.timedelta(1) 174 | scheduled = datetime.datetime.now() + datetime.timedelta(2) 175 | due = datetime.datetime.now() + datetime.timedelta(3) 176 | until = datetime.datetime.now() + datetime.timedelta(4) 177 | 178 | task = tw( 179 | { 180 | "description": "task3", 181 | "wait": wait, 182 | "scheduled": scheduled, 183 | "due": due, 184 | "until": until, 185 | }, 186 | ) 187 | 188 | tc = Taskcal(tw_data_dir=tw_dir) 189 | 190 | compare(task, tc.calendars[""].subcomponents) 191 | 192 | 193 | def test_default_filter(tw): 194 | task = tw({"description": "task"}) 195 | tc = Taskcal(tw_data_dir=tw_dir) 196 | 197 | assert len(tc.calendars[""].subcomponents) == 1 198 | 199 | task.done() 200 | 201 | assert len(tc.calendars[""].subcomponents) == 1 202 | 203 | 204 | def test_overriding_filter(tw): 205 | task = tw({"description": "task"}) 206 | tc = Taskcal(tw_data_dir=tw_dir, filter="status:pending") 207 | 208 | assert len(tc.calendars[""].subcomponents) == 1 209 | 210 | task.done() 211 | 212 | assert len(tc.calendars[""].subcomponents) == 0 213 | -------------------------------------------------------------------------------- /tests/test_output.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | import caldav 8 | import pytest 9 | from icalendar import Calendar, Todo 10 | 11 | from .conftest import tw_dir 12 | 13 | sys.path.insert(0, "./src/") 14 | 15 | from output import CaldavOutput, ICSOutput, Output, resolve_output 16 | from taskcal import icalendar_defaults 17 | 18 | 19 | def test_resolve_output(): 20 | assert resolve_output("ics") == ICSOutput 21 | assert resolve_output("caldav") == CaldavOutput 22 | 23 | with pytest.raises(ValueError) as exception: 24 | resolve_output("not-a-type") 25 | assert str(exception.value) == "invalid output type: not-a-type" 26 | 27 | 28 | def test_abstract_class_instantiation(): 29 | with pytest.raises(TypeError) as exception: 30 | Output() 31 | assert "abstract method sync" in str(exception.value) 32 | 33 | 34 | def test_abstract_class_constructor(): 35 | class Tester(Output): 36 | def sync(self, calendars): 37 | pass 38 | 39 | config = {"one": 1, "two": 2} 40 | t = Tester(config) 41 | 42 | assert Tester({}) 43 | assert t.one == 1 44 | assert t.two == 2 45 | 46 | 47 | def test_abstract_class_sync_enabled(): 48 | class Tester(Output): 49 | def sync(self, calendars): 50 | return super().sync(calendars) 51 | 52 | t = Tester({"enabled": False}) 53 | 54 | assert not t.sync([]) 55 | 56 | 57 | def test_abstract_class_sync_disableed(): 58 | class Tester(Output): 59 | def sync(self, calendars): 60 | return super().sync(calendars) 61 | 62 | t = Tester({}) 63 | 64 | assert t.sync([]) 65 | 66 | 67 | def test_ics_output(tmp_path): 68 | calendars = {} 69 | projects = {} 70 | for index in range(1, 3): 71 | tasks = [] 72 | tasks.append({"summary": f"Todo {2*index-1}"}) 73 | tasks.append({"summary": f"Todo {2*index}"}) 74 | projects[index] = tasks 75 | 76 | calendar = Calendar({"NAME": f"Calendar {index}"}) 77 | calendar.add_component(Todo({"summary": f"Todo {2*index-1}"})) 78 | calendar.add_component(Todo({"summary": f"Todo {2*index}"})) 79 | calendars[f"calendar-{index}"] = calendar 80 | 81 | output = ICSOutput({"dir": tmp_path}) 82 | output.sync(calendars) 83 | 84 | for index in range(1, 3): 85 | ics_file = tmp_path / f"calendar-{index}.ics" 86 | assert Path(ics_file).is_file() 87 | 88 | with open(ics_file) as f: 89 | ics = Calendar.from_ical(f.read()) 90 | 91 | for task_number, todo in enumerate(ics.walk(name="VTODO")): 92 | assert projects[index] 93 | assert todo["summary"] == projects[index][task_number]["summary"] 94 | 95 | 96 | def test_caldav_output(mocker): 97 | calendars = {} 98 | for index in range(0, 3): 99 | calendar = Calendar({"NAME": f"Calendar {index}"} if index else {}) 100 | calendar.update(icalendar_defaults) 101 | calendar.add_component(Todo({"summary": f"Todo {2*index}"})) 102 | calendar.add_component(Todo({"summary": f"Todo {2*index+1}"})) 103 | calendars[f"calendar-{index}"] = calendar 104 | 105 | mocker.patch("caldav.DAVClient", name="DAVClient") 106 | 107 | davclient_return = mocker.Mock(name="DAVClient return") 108 | caldav.DAVClient.return_value = davclient_return 109 | 110 | save_event_mock = mocker.Mock(name="add_event", return_value=True) 111 | calendar_mock = mocker.Mock(name="calendar", save_event=save_event_mock) 112 | make_calendar_mock = mocker.Mock( 113 | name="make_calendar", return_value=calendar_mock 114 | ) 115 | calendars_mock = mocker.Mock( 116 | name="calendars", 117 | return_value=[ 118 | type( 119 | "c", (), {"name": "calendar-0", "save_event": save_event_mock} 120 | )(), 121 | type( 122 | "c", (), {"name": "Calendar 1", "save_event": save_event_mock} 123 | )(), 124 | ], 125 | ) 126 | principal_mock = mocker.Mock( 127 | name="principal", 128 | calendars=calendars_mock, 129 | make_calendar=make_calendar_mock, 130 | ) 131 | davclient_return.principal.return_value = principal_mock 132 | 133 | mocker.seal(caldav.DAVClient) 134 | 135 | output = CaldavOutput( 136 | {"host": "localhost", "username": "demo", "password": "demo"} 137 | ) 138 | output.sync(calendars) 139 | 140 | save_event_calls = [call.args[0] for call in save_event_mock.call_args_list] 141 | for index in range(0, 3): 142 | calendar = Calendar() 143 | todo1 = Calendar.from_ical(save_event_calls[2 * index]) 144 | calendar.update(todo1) # updates only calendar props 145 | calendar.subcomponents.extend(todo1.subcomponents) 146 | calendar.subcomponents.extend( 147 | Calendar.from_ical(save_event_calls[2 * index + 1]).subcomponents 148 | ) 149 | 150 | assert calendars[f"calendar-{index}"] == calendar 151 | for subcomponent in calendar.subcomponents: 152 | assert subcomponent in calendars[f"calendar-{index}"].subcomponents 153 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/env/bin python 2 | 3 | import os 4 | import sys 5 | 6 | import icalendar 7 | import pytest 8 | 9 | from .conftest import tw_dir 10 | 11 | os.environ["TASKDATA"] = tw_dir 12 | 13 | sys.path.insert(0, "./src/") 14 | from taskcal_server import app 15 | 16 | 17 | @pytest.fixture 18 | def client(tw): 19 | def _return(task=None): 20 | tw(task) 21 | return app.test_client() 22 | 23 | return _return 24 | 25 | 26 | def test_non_existent_taskwarrior(): 27 | response = app.test_client().get("/nonexistent.ics") 28 | 29 | assert response.status_code == 500 30 | assert response.data == b"nonexistent TaskWarrior db" 31 | 32 | 33 | def test_non_existent_calendar(client): 34 | response = client().get("/nonexistent.ics") 35 | 36 | assert response.status_code == 404 37 | assert response.data == b"no such calendar" 38 | 39 | 40 | def test_calendar_export(client): 41 | task1 = {"description": "task1"} 42 | response = client(task1).get("/.ics") 43 | 44 | assert response.status_code == 200 45 | 46 | ics = icalendar.Calendar.from_ical(response.data) 47 | 48 | todos = ics.walk(name="VTODO") 49 | assert len(todos) == 1 50 | assert (todos[0]["SUMMARY"]) == "task1" 51 | --------------------------------------------------------------------------------