├── src ├── models │ ├── __init__.py │ ├── habitica.py │ ├── generic_task.py │ └── todoist.py ├── delay.py ├── todoist_api.py ├── tasks_cache.py ├── habitica_api.py ├── config.py └── main.py ├── tests ├── unit │ ├── __init__.py │ ├── test_main.py │ ├── test_changelog.py │ └── test_config.py ├── integration │ ├── __init__.py │ └── mock_test.py ├── conftest.py └── .pylintrc ├── poetry.toml ├── .venv └── .gitignore ├── .sync_cache └── sync_cache.sqlite ├── docker-compose.yml ├── .pydocstyle ├── .env.test ├── Dockerfile ├── .github └── workflows │ └── auto-approve-for-owners.yaml ├── .pre-commit-config.yaml ├── .dockerignore ├── LICENSE ├── documentation └── task state flow.md ├── .env.template ├── .run ├── All tests.run.xml └── Run app.run.xml ├── pyproject.toml ├── .gitignore ├── .circleci └── config.yml ├── CHANGELOG.md ├── README.md └── .pylintrc /src/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true -------------------------------------------------------------------------------- /.venv/.gitignore: -------------------------------------------------------------------------------- 1 | # created by virtualenv automatically 2 | * 3 | -------------------------------------------------------------------------------- /.sync_cache/sync_cache.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radeklat/todoist-habitica-sync/HEAD/.sync_cache/sync_cache.sqlite -------------------------------------------------------------------------------- /tests/integration/mock_test.py: -------------------------------------------------------------------------------- 1 | class TestMock: 2 | @staticmethod 3 | def should_always_pass(): 4 | assert True 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | todoist-habitica-sync: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | args: 7 | - PYTHON_VERSION=3.12.4 8 | image: todoist-habitica-sync:local 9 | container_name: todoist-habitica-sync 10 | env_file: 11 | - .env 12 | volumes: 13 | - ./.sync_cache:/app/.sync_cache 14 | restart: always 15 | -------------------------------------------------------------------------------- /src/models/habitica.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class HabiticaDifficulty(Enum): 5 | """See https://habitica.com/apidoc/#api-Task-CreateUserTasks.""" 6 | 7 | TRIVIAL = "0.1" 8 | EASY = "1" 9 | MEDIUM = "1.5" 10 | HARD = "2" 11 | 12 | def __lt__(self, other): 13 | """To be able to use `min` and `max` on this enum.""" 14 | if isinstance(other, HabiticaDifficulty): 15 | return float(self.value) < float(other.value) 16 | return NotImplemented 17 | -------------------------------------------------------------------------------- /.pydocstyle: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | convention = pep257 3 | match = ^(?!test_)(.*)(? str: 16 | if self.habitica_task_id is None: 17 | raise RuntimeError("Habitica task ID is not set") 18 | return self.habitica_task_id 19 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # TODO: Copy into .env, fill missing data and remove this comment 2 | 3 | # https://todoist.com/prefs/integrations under "API token" 4 | TODOIST_API_KEY= 5 | 6 | # https://habitica.com/user/settings/api 7 | # Under "User ID" 8 | HABITICA_USER_ID= 9 | 10 | # Under "API Token", the "Show API Token" button 11 | HABITICA_API_KEY= 12 | 13 | # Uncomment (remove '#') on lines to change default values 14 | 15 | # Repeat sync automatically after N minutes 16 | #SYNC_DELAY_MINUTES=1 17 | 18 | # Where to store synchronisation details. No need to change. 19 | #DATABASE_FILE=sync_cache.json -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import toml 5 | from delfino.constants import PYPROJECT_TOML_FILENAME 6 | from delfino.models import PyprojectToml 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def project_root(): 11 | return Path(__file__).parent.parent 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def pyproject_toml(project_root): 16 | return PyprojectToml(**toml.load(project_root / PYPROJECT_TOML_FILENAME)) 17 | 18 | 19 | @pytest.fixture(scope="session") 20 | def poetry(pyproject_toml): 21 | assert pyproject_toml.tool.poetry 22 | return pyproject_toml.tool.poetry 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION 2 | FROM python:${PYTHON_VERSION}-slim 3 | 4 | WORKDIR /app 5 | 6 | RUN apt-get --allow-releaseinfo-change update 7 | RUN apt-get install build-essential libssl-dev libffi-dev python3-dev -y 8 | RUN python -m pip install --upgrade pip 9 | 10 | # Doesn't build consistently for armv7 11 | ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 12 | RUN pip install "cryptography<3.5" poetry 13 | 14 | COPY pyproject.toml poetry.lock ./ 15 | 16 | RUN poetry install --only=main --no-root 17 | 18 | # PYTHONPATH set after install to prevent bugs 19 | ENV PYTHONPATH="src" 20 | 21 | COPY . . 22 | 23 | ENTRYPOINT ["poetry", "run", "python", "src/main.py"] 24 | -------------------------------------------------------------------------------- /.github/workflows/auto-approve-for-owners.yaml: -------------------------------------------------------------------------------- 1 | name: Auto Approve for Owners 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - auto_merge_enabled 9 | 10 | jobs: 11 | auto-approve: 12 | runs-on: ubuntu-latest 13 | if: github.event.pull_request.user.login == github.repository_owner && github.event.pull_request.auto_merge != null 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Auto-approve pull request 20 | run: | 21 | curl -s -X POST \ 22 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 23 | -H "Accept: application/vnd.github.v3+json" \ 24 | "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews" \ 25 | -d '{"event": "APPROVE", "body": "Auto-approved because this PR was opened by the repository owner and auto-merge is enabled."}' 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com/ for usage and config 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: formatting 6 | name: formatting 7 | stages: [pre-commit] 8 | language: system 9 | entry: poetry run delfino ruff 10 | pass_filenames: false 11 | - repo: https://github.com/radeklat/settings-doc 12 | rev: '3.0.1' 13 | hooks: 14 | - id: settings-doc-markdown 15 | language: system 16 | args: 17 | - "--class" 18 | - "config.Settings" 19 | - "--heading-offset" 20 | - "1" 21 | - "--update" 22 | - "README.md" 23 | - "--between" 24 | - "" 25 | - "" 26 | - id: settings-doc-dotenv 27 | language: system 28 | args: 29 | - "--class" 30 | - "config.Settings" 31 | - "--update" 32 | - ".env.template" 33 | - "--between" 34 | - "# Auto-generated content start" 35 | - "# Auto-generated content end" 36 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | *.py[cod] 3 | *$py.class 4 | pip-log.txt 5 | pip-delete-this-directory.txt 6 | htmlcov/ 7 | .tox/ 8 | .coverage 9 | .coverage.* 10 | .cache 11 | nosetests.xml 12 | coverage.xml 13 | *.cover 14 | cover/ 15 | .hypothesis/ 16 | .pytest_cache/ 17 | .env* 18 | .venv* 19 | .mypy_cache/ 20 | .idea 21 | .pylintrc 22 | .dockerignore 23 | .gitignore 24 | Dockerfile 25 | README.md 26 | *_test_results.xml 27 | test.sh 28 | requirements-test.txt 29 | CHANGELOG.md 30 | .testrc 31 | tests/ 32 | documentation/ 33 | .github/ 34 | .pre-commit-config.yaml 35 | .run 36 | **/.pylintrc 37 | **/.todoist-sync 38 | .python-version 39 | .circleci 40 | **/sync_cache.json 41 | .git 42 | tasks 43 | 44 | ## Check list of included files with: 45 | #docker image build -t build-context -f - . < None: 26 | """Sleep and prints a message.""" 27 | if delay := max(0.0, self._max_delay - (time.monotonic() - self._last_api_call)): 28 | if self._msg is not None: 29 | self._log.info(self._msg.format(delay=delay)) 30 | time.sleep(delay) 31 | self._last_api_call = time.monotonic() 32 | -------------------------------------------------------------------------------- /documentation/task state flow.md: -------------------------------------------------------------------------------- 1 | # Task state flow chart 2 | 3 | ```mermaid 4 | graph TD 5 | start([START]) --> TodoistNew 6 | TodoistNew --> is_td_deleted{Is TD task\ndeleted?} 7 | is_td_deleted -- No --> is_td_checked{Is TD task\nchecked?} 8 | is_td_deleted -- Yes --> Hidden 9 | is_td_checked -- No --> TodoistActive 10 | is_td_checked -- Yes --> is_td_initial_sync{Initial\nsync?} 11 | is_td_initial_sync -- No --> TodoistActive 12 | is_td_initial_sync -- Yes --> Hidden 13 | TodoistActive --> is_td_deleted_active{Is TD task\ndeleted?} 14 | is_td_deleted_active -- Yes --> Hidden 15 | is_td_deleted_active -- No --> should_td_score_points{Should\nTD task score\npoints?} 16 | should_td_score_points -- Yes --> is_td_owned_by_me{Is TD task\nowned by me?} 17 | should_td_score_points -- No --> TodoistActive 18 | is_td_owned_by_me -- Yes --> HabiticaNew 19 | is_td_owned_by_me -- No --> Hidden 20 | HabiticaNew --> HabiticaCreated 21 | HabiticaCreated --> HabiticaFinished 22 | HabiticaFinished --> is_task_recurring{Is task\nrecurring?} 23 | is_task_recurring -- Yes --> is_task_completed{Is task\ncompleted\nforever?} 24 | is_task_completed -- No --> TodoistActive 25 | is_task_completed -- Yes --> Hidden 26 | is_task_recurring -- No --> Hidden 27 | Hidden --> finish([END]) 28 | ``` 29 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # TODO: Copy into .env, fill missing data and remove this comment 2 | # Auto-generated content start 3 | # See "user_id" mentioned in a link under "Calendar Subscription URL" at https://todoist.com/prefs/integrations. Needed only for shared projects to score points for tasks owned by you. 4 | # TODOIST_USER_ID= 5 | 6 | # See https://todoist.com/prefs/integrations under "API token". 7 | TODOIST_API_KEY= 8 | 9 | # See https://habitica.com/user/settings/api under "User ID". 10 | HABITICA_USER_ID= 11 | 12 | # See https://habitica.com/user/settings/api under "API Token", the "Show API Token" button. 13 | HABITICA_API_KEY= 14 | 15 | # Repeat sync automatically after N minutes. 16 | # SYNC_DELAY_MINUTES=1 17 | 18 | # Where to store synchronisation details. No need to change. 19 | # DATABASE_FILE=.sync_cache/sync_cache.sqlite 20 | 21 | # Defines how Todoist priorities map to Habitica difficulties. Keys/values are case-insensitive and can be both names or numerical values defines by the APIs. See https://habitica.com/apidoc/#api-Task-CreateUserTasks and https://developer.todoist.com/sync/v9/#items for numerical values definitions. 22 | # PRIORITY_TO_DIFFICULTY={"P1": "HARD", "P2": "MEDIUM", "P3": "EASY", "P4": "TRIVIAL"} 23 | 24 | # Defines how Todoist labels map to Habitica difficulties. Keys are case-insensitive. See https://habitica.com/apidoc/#api-Task-CreateUserTasks for difficulty values. If a task has no matching label, the `priority_to_difficulty` mapping is used. If a task has multiple labels, the highest difficulty is used. 25 | # LABEL_TO_DIFFICULTY= 26 | 27 | # Auto-generated content end 28 | -------------------------------------------------------------------------------- /.run/All tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 30 | -------------------------------------------------------------------------------- /tests/unit/test_main.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from config import Settings 4 | from main import TasksSync 5 | from models.habitica import HabiticaDifficulty 6 | from models.todoist import TodoistPriority 7 | 8 | 9 | class TestGetTaskDifficulty: 10 | @staticmethod 11 | @pytest.mark.parametrize( 12 | "labels", 13 | [ 14 | pytest.param([], id="labels are not defined"), 15 | pytest.param(["Urgent"], id="there is no matching label"), 16 | ], 17 | ) 18 | def should_use_task_priority_if(labels: list[str]): 19 | settings = Settings(label_to_difficulty={}) 20 | assert TasksSync._get_task_difficulty(settings, labels, TodoistPriority.P1) == HabiticaDifficulty.HARD 21 | 22 | @staticmethod 23 | @pytest.mark.parametrize( 24 | "labels", 25 | [ 26 | pytest.param(["Urgent"], id="there is a matching label"), 27 | pytest.param(["Urgent", "Important"], id="there are multiple labels"), 28 | ], 29 | ) 30 | def should_use_label_priority_if(labels: list[str]): 31 | settings = Settings(label_to_difficulty={"urgent": HabiticaDifficulty.HARD}) 32 | assert TasksSync._get_task_difficulty(settings, labels, TodoistPriority.P1) == HabiticaDifficulty.HARD 33 | 34 | @staticmethod 35 | def should_use_highest_label_priority(): 36 | settings = Settings( 37 | label_to_difficulty={ 38 | "urgent": HabiticaDifficulty.HARD, 39 | "important": HabiticaDifficulty.MEDIUM, 40 | } 41 | ) 42 | labels = ["Urgent", "Important"] 43 | assert TasksSync._get_task_difficulty(settings, labels, TodoistPriority.P1) == HabiticaDifficulty.HARD 44 | -------------------------------------------------------------------------------- /.run/Run app.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 35 | -------------------------------------------------------------------------------- /src/models/todoist.py: -------------------------------------------------------------------------------- 1 | """Models matching the v9 SYnc API. 2 | 3 | See Also: https://developer.todoist.com/sync/v9/#read-resources 4 | """ 5 | 6 | from enum import Enum 7 | from typing import TypeAlias 8 | 9 | from dateutil.parser import parse 10 | from pydantic import BaseModel, ConfigDict, Field, field_validator 11 | 12 | 13 | class TodoistPriority(Enum): 14 | P1 = 4 15 | P2 = 3 16 | P3 = 2 17 | P4 = 1 18 | 19 | 20 | class TodoistDue(BaseModel): 21 | date: str 22 | timezone: str | None = None 23 | string: str 24 | lang: str 25 | is_recurring: bool 26 | 27 | 28 | class TodoistTask(BaseModel): 29 | checked: bool 30 | content: str 31 | due: TodoistDue | None = None 32 | id: str 33 | is_deleted: bool 34 | priority: int 35 | responsible_uid: str | None = None 36 | completed_at: str | None = None 37 | labels: list[str] = Field(default_factory=list) 38 | 39 | # custom fields with default value 40 | due_date_utc_timestamp: int | None = None 41 | completed_at_utc_timestamp: int | None = None 42 | 43 | @property 44 | def latest_completion(self) -> int: 45 | return max(self.due_date_utc_timestamp or 0, self.completed_at_utc_timestamp or 0) 46 | 47 | @property 48 | def is_recurring(self) -> bool: 49 | return self.due is not None and self.due.is_recurring 50 | 51 | def __init__(self, **data): 52 | super().__init__(**data) 53 | if (due := data.get("due", None)) is not None: 54 | self.due_date_utc_timestamp = int(parse(due["date"]).timestamp()) 55 | if (completed_at := data.get("completed_at", None)) is not None: 56 | self.completed_at_utc_timestamp = int(parse(completed_at).timestamp()) 57 | 58 | model_config = ConfigDict(extra="allow") 59 | 60 | 61 | class CompletedTodoistTask(BaseModel): 62 | task_id: str 63 | user_id: str 64 | completed_at: str 65 | item_object: TodoistTask 66 | 67 | 68 | TodoistTasks: TypeAlias = dict[str, TodoistTask] 69 | 70 | 71 | class TodoistState(BaseModel): 72 | sync_token: str 73 | full_sync: bool 74 | items: TodoistTasks = Field(default_factory=dict) 75 | 76 | @field_validator("items", mode="before") 77 | @classmethod 78 | def items_list_to_dict(cls, items: list[dict]) -> dict[str, dict]: # pylint: disable=no-self-argument 79 | return {item["id"]: item for item in items} 80 | -------------------------------------------------------------------------------- /src/todoist_api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Iterator 3 | from http import HTTPStatus 4 | 5 | import requests 6 | from pydantic import BaseModel 7 | 8 | from models.todoist import CompletedTodoistTask 9 | 10 | 11 | class QueryParamsCompletedGetAll(BaseModel): 12 | """See https://developer.todoist.com/sync/v9/#get-all-completed-items.""" 13 | 14 | limit: int = 200 15 | since: str | None 16 | annotate_items: bool = True 17 | 18 | 19 | class TodoistAPI: 20 | _SYNC_VERSION = "v9" 21 | _BASE_URL = f"https://api.todoist.com/sync/{_SYNC_VERSION}" 22 | _ENDPOINT_COMPLETED_GET_ALL = f"{_BASE_URL}/completed/get_all" 23 | 24 | def __init__(self, token: str, last_sync_datetime_utc: str | None = None) -> None: 25 | self._session = requests.Session() 26 | self._headers = {"Authorization": f"Bearer {token}"} 27 | self._last_sync_datetime_utc: str | None = last_sync_datetime_utc 28 | self._completed_tasks: list[CompletedTodoistTask] = [] 29 | self._log = logging.getLogger(self.__class__.__name__) 30 | 31 | def iter_pop_newly_completed_tasks(self) -> Iterator[CompletedTodoistTask]: 32 | """Pop tasks from last sync.""" 33 | while self._completed_tasks: 34 | yield self._completed_tasks.pop() 35 | 36 | def sync(self) -> str | None: 37 | """Sync recently completed tasks. 38 | 39 | Returns: Last sync datetime to persist. 40 | """ 41 | response = self._session.get( 42 | self._ENDPOINT_COMPLETED_GET_ALL, 43 | headers=self._headers, 44 | params=QueryParamsCompletedGetAll(since=self._last_sync_datetime_utc).model_dump(exclude_none=True), 45 | ) 46 | 47 | if response.status_code == HTTPStatus.FORBIDDEN: 48 | raise RuntimeError( 49 | "Invalid API token for Todoist. Please check that is matches the one " 50 | "from https://todoist.com/app/settings/integrations/developer." 51 | ) 52 | 53 | response.raise_for_status() 54 | 55 | if newly_completed_tasks := [CompletedTodoistTask(**data) for data in response.json()["items"]]: 56 | self._log.info(f"Synced {len(newly_completed_tasks)} new completed tasks.") 57 | self._last_sync_datetime_utc = newly_completed_tasks[0].completed_at 58 | self._completed_tasks.extend(newly_completed_tasks) 59 | else: 60 | self._log.debug("No new completed tasks.") 61 | 62 | return self._last_sync_datetime_utc 63 | -------------------------------------------------------------------------------- /tests/unit/test_changelog.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import Counter 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture(scope="module") 9 | def changelog() -> str: 10 | with open(Path(__file__).parent.parent.parent / "CHANGELOG.md", encoding="utf8") as changelog: 11 | return changelog.read() 12 | 13 | 14 | @pytest.fixture(scope="module") 15 | def all_versions(changelog) -> list[str]: 16 | return re.findall(r"## \[([0-9]+\.[0-9]+\.[0-9]+)\]", changelog) 17 | 18 | 19 | class TestChangelog: 20 | @staticmethod 21 | def should_contain_current_version(poetry, all_versions): 22 | assert all_versions[0] == poetry.version 23 | 24 | @staticmethod 25 | def should_contain_each_version_only_once(all_versions): 26 | cnt = Counter(all_versions) 27 | for version, count in cnt.most_common(): 28 | if count > 1: 29 | raise AssertionError(f"Version '{version}' appears {count}x in the changelog.") 30 | break 31 | 32 | @staticmethod 33 | def should_use_latest_version_in_the_unreleased_section_link(poetry, changelog): 34 | regex = r"\[Unreleased\]: " + poetry.homepage + r"/compare/" + poetry.version + r"\.\.\.HEAD" 35 | links = re.findall(regex, changelog) 36 | assert len(links) == 1, f"Pattern '{regex}' must be present exactly once." 37 | 38 | @staticmethod 39 | def should_link_to_the_current_version(poetry, changelog): 40 | regex = r"\[" + poetry.version + r"\]: " + poetry.homepage + r"/compare/.*\.\.\." + poetry.version 41 | links = re.findall(regex, changelog) 42 | assert len(links) == 1, f"Pattern '{regex}' must be present exactly once." 43 | 44 | @staticmethod 45 | def should_contain_version_and_date_in_add_second_level_headings(changelog): 46 | headings = re.findall(r"\n## .*", changelog) 47 | assert headings, "The should be at least one second level heading" 48 | regex = r"\n## \[[0-9]+\.[0-9]+\.[0-9]+\] - [0-9]{4}-[0-9]{2}-[0-9]{2}" 49 | 50 | assert headings[0] == "\n## [Unreleased]", "First heading must be for unreleased changes" 51 | headings.pop(0) 52 | 53 | for heading in headings: 54 | match = re.match(regex, heading) 55 | assert match, f"Heading '{heading}' doesn't match the '## [] - ' pattern " 56 | 57 | @staticmethod 58 | def should_contain_change_type_in_third_level_headings(changelog): 59 | headings = set(re.findall(r"\n### (.*)", changelog)) 60 | assert headings, "The should be at least one thrid level heading" 61 | assert headings <= {"Features", "Fixes", "Deprecated", "Breaking changes"} 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "todoist-habitica-sync" 3 | version = "4.0.1" 4 | description = "One way synchronisation from Todoist to Habitica." 5 | authors = ["Radek Lát "] 6 | homepage = "https://github.com/radeklat/todoist-habitica-sync" 7 | license = "MIT License" 8 | package-mode = false 9 | 10 | [tool.poetry.dependencies] 11 | python = "==3.12.4" 12 | tinydb = "^4.5.2" 13 | ujson = "^5.1.0" 14 | dotmap = "^1.3" 15 | requests = "^2.22" 16 | python-dateutil = "^2.8" 17 | pydantic = "^2.0" 18 | pydantic-settings = "^2.0" 19 | 20 | [tool.poetry.group.dev.dependencies] 21 | poetry = "^1.6" 22 | types-toml = "*" 23 | types-requests = "*" 24 | types-python-dateutil = "*" 25 | settings-doc = "^4.3.2" 26 | pytest-dotenv = "^0.5.2" 27 | delfino-core = {extras = ["verify", "dependencies-update"], version = "^9.0"} 28 | toml = "^0.10.2" 29 | delfino-docker = "^4.0" # https://github.com/radeklat/delfino-docker/blob/main/CHANGELOG.md 30 | ruff = "^0.11.0" 31 | 32 | [build-system] 33 | requires = ["poetry-core>=1.0.0"] 34 | build-backend = "poetry.core.masonry.api" 35 | 36 | [tool.pytest.ini_options] 37 | testpaths = "tests/unit tests/integration" 38 | python_functions = "should_*" 39 | junit_family = "xunit2" 40 | # pytest-dotenv settings 41 | env_override_existing_values = 1 42 | env_files = ".env.test" 43 | 44 | # Structure: https://docs.pytest.org/en/stable/warnings.html 45 | # Values: https://docs.python.org/3/library/warnings.html#describing-warning-filters 46 | # action:message:category:module:line 47 | filterwarnings = [ 48 | 49 | ] 50 | 51 | markers = [ 52 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 53 | ] 54 | 55 | [tool.mypy] 56 | show_column_numbers = true 57 | show_error_codes = true 58 | color_output = true 59 | warn_unused_configs = true 60 | warn_unused_ignores = true 61 | check_untyped_defs = true 62 | follow_imports = "silent" 63 | plugins = ["pydantic.mypy"] 64 | 65 | [[tool.mypy.overrides]] 66 | # Source code dependencies 67 | module = [ 68 | "delfino.*", 69 | ] 70 | ignore_missing_imports = true 71 | 72 | [tool.delfino.plugins.delfino-core] 73 | 74 | [tool.delfino.plugins.delfino-docker.docker_build] 75 | dockerhub_username = "radeklat" 76 | build_for_platforms = [ 77 | "linux/arm/v7", 78 | "linux/arm64", 79 | "linux/amd64", 80 | ] 81 | 82 | [tool.ruff] 83 | line-length = 120 84 | fix = true 85 | 86 | 87 | [tool.ruff.lint] 88 | select = [ 89 | "C90", # mccabe 90 | "D", # pydocstyle 91 | "E", # pycodestyle, errors 92 | "F", # Pyflakes 93 | "I", # isort 94 | "N", # PEP8-naming 95 | "PL", # Pylint 96 | "UP", # pyupgrade 97 | "W", # pycodestyle, warning 98 | ] 99 | ignore = [ 100 | # See https://docs.astral.sh/ruff/rules/#pydocstyle-d 101 | "D1", # Missing docstrings. We rely on code reviews. Names are often descriptive enough and don't need additional docstring. 102 | "D401", # "First line should be in imperative mood" 103 | ] 104 | 105 | [tool.ruff.lint.per-file-ignores] 106 | "tests/**" = [ 107 | "D102", # missing-documentation-for-public-method 108 | ] 109 | 110 | [tool.ruff.lint.pylint] 111 | max-args = 6 112 | 113 | [tool.ruff.lint.pydocstyle] 114 | convention = "google" 115 | 116 | [tool.ruff.lint.pep8-naming] 117 | # Allow Pydantic's `@validator` decorator to trigger class method treatment. 118 | classmethod-decorators = ["classmethod", "pydantic.field_validator"] 119 | 120 | [tool.ruff.lint.mccabe] 121 | max-complexity = 10 122 | -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from config import _DEFAULT_PRIORITY_TO_DIFFICULTY, Settings, get_settings 4 | from models.habitica import HabiticaDifficulty 5 | from models.todoist import TodoistPriority 6 | 7 | 8 | class TestConfigPriorityToDifficulty: 9 | @staticmethod 10 | def should_refuse_incomplete_mapping(): 11 | with pytest.raises(ValueError) as exc_info: 12 | Settings(priority_to_difficulty={TodoistPriority.P1: HabiticaDifficulty.HARD}) 13 | 14 | exception_text = str(exc_info.value) 15 | 16 | assert "must have all priority levels defined" in exception_text 17 | assert "missing: P2, P3, P4" in exception_text 18 | 19 | @staticmethod 20 | def should_accept_keys_as_enum_names(): 21 | settings = Settings( 22 | priority_to_difficulty={ 23 | "p1": "hard", 24 | "p2": "medium", 25 | "p3": "easy", 26 | "p4": "trivial", 27 | } 28 | ) 29 | assert settings.priority_to_difficulty == _DEFAULT_PRIORITY_TO_DIFFICULTY 30 | 31 | @staticmethod 32 | def should_ignore_casing(): 33 | settings = Settings( 34 | priority_to_difficulty={ 35 | "P1": "Hard", 36 | "p2": "Medium", 37 | "P3": "Easy", 38 | "P4": "Trivial", 39 | } 40 | ) 41 | assert settings.priority_to_difficulty == _DEFAULT_PRIORITY_TO_DIFFICULTY 42 | 43 | @staticmethod 44 | def should_accept_keys_as_direct_enum_values(): 45 | settings = Settings(priority_to_difficulty={4: "2", 3: "1.5", 2: "1", 1: "0.1"}) 46 | assert settings.priority_to_difficulty == _DEFAULT_PRIORITY_TO_DIFFICULTY 47 | 48 | @staticmethod 49 | def should_accept_keys_as_indirect_enum_values(): 50 | settings = Settings(priority_to_difficulty={"4": 2, "3": 1.5, "2": 1, "1": 0.1}) 51 | assert settings.priority_to_difficulty == _DEFAULT_PRIORITY_TO_DIFFICULTY 52 | 53 | @staticmethod 54 | def should_have_default_values(): 55 | assert Settings().priority_to_difficulty == _DEFAULT_PRIORITY_TO_DIFFICULTY 56 | 57 | 58 | class TestConfigLabelToDifficulty: 59 | @staticmethod 60 | def should_accept_case_insensitive_labels(): 61 | settings = Settings( 62 | label_to_difficulty={ 63 | "Urgent": "hard", 64 | "Important": "medium", 65 | "Normal": "easy", 66 | "Low": "trivial", 67 | } 68 | ) 69 | assert settings.label_to_difficulty == { 70 | "urgent": HabiticaDifficulty.HARD, 71 | "important": HabiticaDifficulty.MEDIUM, 72 | "normal": HabiticaDifficulty.EASY, 73 | "low": HabiticaDifficulty.TRIVIAL, 74 | } 75 | 76 | @staticmethod 77 | def should_allow_missing_difficulty_values(): 78 | settings = Settings(label_to_difficulty={"Urgent": "Hard"}) 79 | assert settings.label_to_difficulty == {"urgent": HabiticaDifficulty.HARD} 80 | 81 | @staticmethod 82 | def should_allow_multiple_identical_difficulty_values(): 83 | settings = Settings(label_to_difficulty={"Urgent": "Hard", "Important": "Hard"}) 84 | assert settings.label_to_difficulty == { 85 | "urgent": HabiticaDifficulty.HARD, 86 | "important": HabiticaDifficulty.HARD, 87 | } 88 | 89 | @staticmethod 90 | def should_have_default_empty_label_to_difficulty(): 91 | assert Settings().label_to_difficulty == {} 92 | 93 | 94 | class TestGetSettings: 95 | @staticmethod 96 | def should_cache_settings(): 97 | assert get_settings() is get_settings() 98 | -------------------------------------------------------------------------------- /src/tasks_cache.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import sqlite3 4 | from collections.abc import Iterator 5 | from collections.abc import Iterator as TypingIterator 6 | from contextlib import contextmanager 7 | from datetime import datetime, timezone 8 | 9 | from config import get_settings 10 | from models.generic_task import GenericTask 11 | 12 | _DATABASE_SCHEMAS = [ 13 | """ 14 | CREATE TABLE IF NOT EXISTS tasks_cache ( 15 | id TEXT PRIMARY KEY NOT NULL, 16 | task_data TEXT 17 | ) 18 | """, 19 | """ 20 | CREATE TABLE IF NOT EXISTS metadata ( 21 | key TEXT PRIMARY KEY NOT NULL, 22 | value TEXT 23 | ) 24 | """, 25 | ] 26 | 27 | 28 | class TasksCache: 29 | """Tasks cache on disk using SQLite.""" 30 | 31 | def __init__(self): 32 | db_file = get_settings().database_file # pylint: disable=no-member 33 | db_file.parent.mkdir(parents=True, exist_ok=True) # pylint: disable=no-member 34 | self._db_path = str(db_file.resolve()) # pylint: disable=no-member 35 | self._log = logging.getLogger(self.__class__.__name__) 36 | self._log.info(f"Tasks cache in {db_file.absolute()}") # pylint: disable=no-member 37 | 38 | self._initialize_database() 39 | 40 | @contextmanager 41 | def _cursor(self, row_factory=None) -> TypingIterator[sqlite3.Cursor]: 42 | """Context manager for database cursor operations.""" 43 | conn = sqlite3.connect(self._db_path) 44 | if row_factory: 45 | conn.row_factory = row_factory 46 | cursor = conn.cursor() 47 | try: 48 | yield cursor 49 | conn.commit() 50 | finally: 51 | conn.close() 52 | 53 | def _initialize_database(self) -> None: 54 | with self._cursor() as cursor: 55 | for database_schema in _DATABASE_SCHEMAS: 56 | cursor.execute(database_schema) 57 | 58 | def _read_metadata(self, key: str, default: str | None = None) -> str | None: 59 | with self._cursor() as cursor: 60 | cursor.execute("SELECT value FROM metadata WHERE key = ?", (key,)) 61 | return row[0] if (row := cursor.fetchone()) else default 62 | 63 | def _write_metadata(self, key: str, value: str) -> None: 64 | with self._cursor() as cursor: 65 | cursor.execute( 66 | "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)", 67 | (key, value), 68 | ) 69 | 70 | @property 71 | def last_sync_datetime_utc(self) -> str | None: 72 | return self._read_metadata( 73 | "last_sync_datetime_utc", 74 | datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), 75 | ) 76 | 77 | @last_sync_datetime_utc.setter 78 | def last_sync_datetime_utc(self, value: str) -> None: 79 | self._write_metadata("last_sync_datetime_utc", value) 80 | 81 | def save_task(self, generic_task: GenericTask) -> None: 82 | with self._cursor() as cursor: 83 | cursor.execute( 84 | "INSERT OR REPLACE INTO tasks_cache (id, task_data) VALUES (?, ?)", 85 | (str(generic_task.id), generic_task.model_dump_json()), 86 | ) 87 | 88 | def delete_task(self, generic_task: GenericTask) -> None: 89 | with self._cursor() as cursor: 90 | cursor.execute("DELETE FROM tasks_cache WHERE id = ?", (str(generic_task.id),)) 91 | 92 | def in_progress_tasks(self) -> Iterator[GenericTask]: 93 | while True: 94 | with self._cursor(row_factory=sqlite3.Row) as cursor: 95 | cursor.execute("SELECT task_data FROM tasks_cache LIMIT 1") 96 | 97 | if not (row := cursor.fetchone()): 98 | break 99 | 100 | yield GenericTask(**json.loads(row["task_data"])) 101 | -------------------------------------------------------------------------------- /src/habitica_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Final 3 | 4 | import requests 5 | from pydantic import BaseModel, ConfigDict, Field 6 | 7 | from delay import DelayTimer 8 | from models.habitica import HabiticaDifficulty 9 | 10 | _API_URI_BASE: Final[str] = "https://habitica.com/api/v3" 11 | _SUCCESS_CODES = frozenset([requests.codes.ok, requests.codes.created]) # pylint: disable=no-member 12 | _API_CALLS_DELAY: Final[DelayTimer] = DelayTimer(30, "Waiting for {delay:.0f}s between API calls.") 13 | """https://habitica.fandom.com/wiki/Guidance_for_Comrades#API_Server_Calls""" 14 | 15 | 16 | class HabiticaAPIHeaders(BaseModel): 17 | user_id: str = Field(..., alias="x-api-user") 18 | api_key: str = Field(..., alias="x-api-key") 19 | client_id: str = Field("fb0ab2bf-675d-4326-83ba-d03eefe24cef-todoist-habitica-sync", alias="x-client") 20 | content_type: str = Field("application/json", alias="content-type") 21 | model_config = ConfigDict(populate_by_name=True) 22 | 23 | 24 | class HabiticaAPI: 25 | """Access to Habitica API. 26 | 27 | Based on https://github.com/philadams/habitica/blob/master/habitica/api.py 28 | """ 29 | 30 | def __init__( 31 | self, 32 | headers: HabiticaAPIHeaders, 33 | resource: str | None = None, 34 | aspect: str | None = None, 35 | ): 36 | self._resource = resource 37 | self._aspect = aspect 38 | self._headers = headers 39 | 40 | def __getattr__(self, name): 41 | try: 42 | return object.__getattribute__(self, name) 43 | except AttributeError: 44 | if not self._resource: 45 | return HabiticaAPI(headers=self._headers, resource=name) 46 | 47 | return HabiticaAPI(headers=self._headers, resource=self._resource, aspect=name) 48 | 49 | def __call__(self, **kwargs): 50 | method = kwargs.pop("_method", "get") 51 | 52 | # build up URL... Habitica's api is the *teeniest* bit annoying 53 | # so either I need to find a cleaner way here, or I should 54 | # get involved in the API itself and... help it. 55 | if self._aspect: 56 | aspect_id = kwargs.pop("_id", None) 57 | direction = kwargs.pop("_direction", None) 58 | uri = _API_URI_BASE 59 | if aspect_id is not None: 60 | uri = f"{uri}/{self._aspect}/{aspect_id}" 61 | elif self._aspect == "tasks": 62 | uri = f"{uri}/{self._aspect}/{self._resource}" 63 | else: 64 | uri = f"{uri}/{self._resource}/{self._aspect}" 65 | if direction is not None: 66 | uri = f"{uri}/score/{direction}" 67 | else: 68 | uri = f"{_API_URI_BASE}/{self._resource}" 69 | 70 | # actually make the request of the API 71 | http_headers = self._headers.model_dump(by_alias=True) 72 | _API_CALLS_DELAY() 73 | if method in ["put", "post", "delete"]: 74 | res = getattr(requests, method)(uri, headers=http_headers, data=json.dumps(kwargs)) 75 | else: 76 | res = getattr(requests, method)(uri, headers=http_headers, params=kwargs) 77 | 78 | # print(res.url) # debug... 79 | if res.status_code not in _SUCCESS_CODES: 80 | try: 81 | res.raise_for_status() 82 | except requests.HTTPError as exc: 83 | raise requests.HTTPError(f"{exc}, JSON Payload: {res.json()}", res) from exc 84 | 85 | return res.json()["data"] 86 | 87 | def create_task(self, text: str, priority: HabiticaDifficulty) -> dict[str, Any]: 88 | """See https://habitica.com/apidoc/#api-Task-CreateUserTasks.""" 89 | return self.user.tasks(type="todo", text=text, priority=priority.value, _method="post") 90 | 91 | def score_task(self, task_id: str, direction: str = "up") -> None: 92 | """See https://habitica.com/apidoc/#api-Task-ScoreTask.""" 93 | return self.user.tasks(_id=task_id, _direction=direction, _method="post") 94 | 95 | def delete_task(self, task_id: str) -> None: 96 | """See https://habitica.com/apidoc/#api-Task-DeleteTask.""" 97 | return self.user.tasks(_id=task_id, _method="delete") 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### macOS template 3 | # General 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | ### Linux template 30 | *~ 31 | 32 | # temporary files which can be created if a process still has a handle open of a deleted file 33 | .fuse_hidden* 34 | 35 | # KDE directory preferences 36 | .directory 37 | 38 | # Linux trash folder which might appear on any partition or disk 39 | .Trash-* 40 | 41 | # .nfs files are created when an open file is removed but is still being accessed 42 | .nfs* 43 | ### Windows template 44 | # Windows thumbnail cache files 45 | Thumbs.db 46 | ehthumbs.db 47 | ehthumbs_vista.db 48 | 49 | # Dump file 50 | *.stackdump 51 | 52 | # Folder config file 53 | [Dd]esktop.ini 54 | 55 | # Recycle Bin used on file shares 56 | $RECYCLE.BIN/ 57 | 58 | # Windows Installer files 59 | *.cab 60 | *.msi 61 | *.msix 62 | *.msm 63 | *.msp 64 | 65 | # Windows shortcuts 66 | *.lnk 67 | ### Python template 68 | # Byte-compiled / optimized / DLL files 69 | __pycache__/ 70 | *.py[cod] 71 | *$py.class 72 | 73 | # C extensions 74 | *.so 75 | 76 | # Distribution / packaging 77 | .Python 78 | /build/ 79 | develop-eggs/ 80 | dist/ 81 | downloads/ 82 | eggs/ 83 | .eggs/ 84 | lib/ 85 | lib64/ 86 | parts/ 87 | sdist/ 88 | var/ 89 | wheels/ 90 | *.egg-info/ 91 | .installed.cfg 92 | *.egg 93 | MANIFEST 94 | 95 | # PyInstaller 96 | # Usually these files are written by a python script from a template 97 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 98 | *.manifest 99 | *.spec 100 | 101 | # Installer logs 102 | pip-log.txt 103 | pip-delete-this-directory.txt 104 | 105 | # Unit test / coverage reports 106 | htmlcov/ 107 | .tox/ 108 | .coverage 109 | .coverage.* 110 | .cache 111 | nosetests.xml 112 | coverage.xml 113 | *.cover 114 | .hypothesis/ 115 | .pytest_cache/ 116 | 117 | # Translations 118 | *.mo 119 | *.pot 120 | 121 | # Django stuff: 122 | *.log 123 | local_settings.py 124 | db.sqlite3 125 | 126 | # Flask stuff: 127 | instance/ 128 | .webassets-cache 129 | 130 | # Scrapy stuff: 131 | .scrapy 132 | 133 | # Sphinx documentation 134 | docs/_build/ 135 | 136 | # PyBuilder 137 | target/ 138 | 139 | # Jupyter Notebook 140 | .ipynb_checkpoints 141 | 142 | # pyenv 143 | .python-version 144 | 145 | # celery beat schedule file 146 | celerybeat-schedule 147 | 148 | # SageMath parsed files 149 | *.sage.py 150 | 151 | # Environments 152 | .env 153 | env/ 154 | venv/ 155 | ENV/ 156 | env.bak/ 157 | venv.bak/ 158 | 159 | # Spyder project settings 160 | .spyderproject 161 | .spyproject 162 | 163 | # Rope project settings 164 | .ropeproject 165 | 166 | # mkdocs documentation 167 | /site 168 | 169 | # mypy 170 | .mypy_cache/ 171 | 172 | ### TortoiseGit template 173 | # Project-level settings 174 | /.tgitconfig 175 | ### Vim template 176 | # Swap 177 | [._]*.s[a-v][a-z] 178 | [._]*.sw[a-p] 179 | [._]s[a-v][a-z] 180 | [._]sw[a-p] 181 | 182 | # Session 183 | Session.vim 184 | 185 | # Temporary 186 | .netrwhist 187 | # Auto-generated tag files 188 | tags 189 | # Persistent undo 190 | [._]*.un~ 191 | ### VirtualEnv template 192 | # Virtualenv 193 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 194 | [Bb]in 195 | [Ii]nclude 196 | [Ll]ib 197 | [Ll]ib64 198 | [Ll]ocal 199 | [Ss]cripts 200 | pyvenv.cfg 201 | pip-selfcheck.json 202 | 203 | ### JetBrains template 204 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 205 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 206 | 207 | # Gradle and Maven with auto-import 208 | # When using Gradle or Maven with auto-import, you should exclude module files, 209 | # since they will be recreated, and may cause churn. Uncomment if using 210 | # auto-import. 211 | # .idea/modules.xml 212 | # .idea/*.iml 213 | # .idea/modules 214 | # *.iml 215 | # *.ipr 216 | 217 | # CMake 218 | cmake-build-*/ 219 | 220 | # File-based project format 221 | *.iws 222 | 223 | # IntelliJ 224 | out/ 225 | 226 | # mpeltonen/sbt-idea plugin 227 | .idea_modules/ 228 | 229 | # JIRA plugin 230 | atlassian-ide-plugin.xml 231 | 232 | # Crashlytics plugin (for Android Studio and IntelliJ) 233 | com_crashlytics_export_strings.xml 234 | crashlytics.properties 235 | crashlytics-build.properties 236 | fabric.properties 237 | 238 | .idea 239 | cover 240 | .hypothesis 241 | *_test_results.xml 242 | .todoist-sync/ 243 | sync_cache.json 244 | reports -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | from pydantic import Field, field_validator 6 | from pydantic_settings import BaseSettings, SettingsConfigDict 7 | 8 | from models.habitica import HabiticaDifficulty 9 | from models.todoist import TodoistPriority 10 | 11 | _DEFAULT_PRIORITY_TO_DIFFICULTY = { 12 | TodoistPriority.P1: HabiticaDifficulty.HARD, 13 | TodoistPriority.P2: HabiticaDifficulty.MEDIUM, 14 | TodoistPriority.P3: HabiticaDifficulty.EASY, 15 | TodoistPriority.P4: HabiticaDifficulty.TRIVIAL, 16 | } 17 | 18 | 19 | class Settings(BaseSettings): 20 | model_config = SettingsConfigDict(env_file_encoding="utf-8") 21 | 22 | todoist_user_id: int | None = Field( 23 | None, 24 | description=( 25 | 'See "user_id" mentioned in a link under "Calendar Subscription URL" at ' 26 | "https://todoist.com/prefs/integrations. Needed only for shared projects to " 27 | "score points for tasks owned by you." 28 | ), 29 | ) 30 | todoist_api_key: str = Field(..., description='See https://todoist.com/prefs/integrations under "API token".') 31 | habitica_user_id: str = Field(..., description='See https://habitica.com/user/settings/api under "User ID".') 32 | habitica_api_key: str = Field( 33 | ..., 34 | description='See https://habitica.com/user/settings/api under "API Token", the "Show API Token" button.', 35 | ) 36 | sync_delay_seconds: int = Field( 37 | 1, 38 | gt=0, 39 | validation_alias="sync_delay_minutes", 40 | description="Repeat sync automatically after N minutes.", 41 | ) 42 | database_file: Path = Field( 43 | Path(".sync_cache/sync_cache.sqlite"), 44 | description="Where to store synchronisation details. No need to change.", 45 | ) 46 | priority_to_difficulty: dict[TodoistPriority, HabiticaDifficulty] = Field( 47 | # The default is formed of the enum names for better documentation 48 | {key.name: value.name for key, value in _DEFAULT_PRIORITY_TO_DIFFICULTY.items()}, # type: ignore[misc] 49 | description=( 50 | "Defines how Todoist priorities map to Habitica difficulties. Keys/values are case-insensitive " 51 | "and can be both names or numerical values defines by the APIs. " 52 | "See https://habitica.com/apidoc/#api-Task-CreateUserTasks and " 53 | "https://developer.todoist.com/sync/v9/#items for numerical values definitions." 54 | ), 55 | ) 56 | label_to_difficulty: dict[str, HabiticaDifficulty] = Field( 57 | default_factory=dict, 58 | description=( 59 | "Defines how Todoist labels map to Habitica difficulties. Keys are case-insensitive. " 60 | "See https://habitica.com/apidoc/#api-Task-CreateUserTasks for difficulty values. If a task " 61 | "has no matching label, the `priority_to_difficulty` mapping is used. If a task has multiple " 62 | "labels, the highest difficulty is used." 63 | ), 64 | ) 65 | 66 | @field_validator("sync_delay_seconds") 67 | @classmethod 68 | def minutes_to_seconds(cls, value: int): # pylint: disable=no-self-argument 69 | return value * 60 70 | 71 | @field_validator("priority_to_difficulty", mode="before") 72 | @classmethod 73 | def transform_enum_names_to_values( 74 | cls, 75 | priority_to_difficulty: dict[str | int | float | TodoistPriority, str | HabiticaDifficulty], 76 | ) -> dict[TodoistPriority | str, HabiticaDifficulty | float | int]: 77 | output: dict[Any, Any] = {} # disable type checking for the dict values as it gets it wrong and tests cover it 78 | 79 | for priority, difficulty in priority_to_difficulty.items(): 80 | new_priority = priority 81 | new_difficulty = difficulty 82 | 83 | if isinstance(priority, str): 84 | try: 85 | new_priority = int(priority) 86 | except ValueError: 87 | # If it's not a number, it could be an enum name 88 | new_priority = TodoistPriority[priority.upper()] 89 | 90 | if isinstance(difficulty, str): 91 | try: 92 | float(difficulty) # If it's a number, it's an enum value 93 | except ValueError: # If it's not a number, it's an enum name 94 | new_difficulty = HabiticaDifficulty[difficulty.upper()] 95 | 96 | if isinstance(difficulty, (float, int)): # If it's a number, it's an enum value 97 | new_difficulty = str(difficulty) 98 | 99 | output[new_priority] = new_difficulty 100 | 101 | return output 102 | 103 | @field_validator("priority_to_difficulty") 104 | @classmethod 105 | def validate_priority_to_difficulty(cls, value: dict[TodoistPriority, HabiticaDifficulty]): 106 | # The dict must have all keys from TodoistPriority 107 | if missing_keys := ", ".join(sorted(_.name for _ in set(TodoistPriority) - set(value.keys()))): 108 | raise ValueError( 109 | f"priority_to_difficulty must have all priority levels defined, but missing: {missing_keys}" 110 | ) 111 | return value 112 | 113 | @field_validator("label_to_difficulty", mode="before") 114 | @classmethod 115 | def transform_label_to_difficulty( 116 | cls, label_to_difficulty: dict[str, str | HabiticaDifficulty] 117 | ) -> dict[str, HabiticaDifficulty]: 118 | return { 119 | label.lower(): (HabiticaDifficulty[difficulty.upper()] if isinstance(difficulty, str) else difficulty) 120 | for label, difficulty in label_to_difficulty.items() 121 | } 122 | 123 | 124 | @lru_cache(maxsize=1) 125 | def get_settings(): 126 | # We don't want to read the environment variables in the tests 127 | Settings.model_config["env_file"] = ".env" 128 | 129 | return Settings() 130 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from http import HTTPStatus 5 | from typing import Final 6 | 7 | from pydantic import BaseModel, ConfigDict 8 | from requests import HTTPError 9 | 10 | from config import Settings, get_settings 11 | from delay import DelayTimer 12 | from habitica_api import HabiticaAPI, HabiticaAPIHeaders 13 | from models.generic_task import GenericTask 14 | from models.habitica import HabiticaDifficulty 15 | from models.todoist import TodoistPriority 16 | from tasks_cache import TasksCache 17 | from todoist_api import TodoistAPI 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | class FSMState(BaseModel): 23 | context: TasksSync 24 | generic_task: GenericTask 25 | 26 | _STATES: Final[dict[str, type[FSMState]]] = {} 27 | 28 | model_config = ConfigDict(arbitrary_types_allowed=True) 29 | 30 | def _set_state(self, state_cls: type[FSMState]) -> None: 31 | if self.__class__ is not state_cls: # transition to a different state 32 | self.context.set_state(state_cls(**self.model_dump())) 33 | 34 | def next_state(self) -> None: 35 | raise NotImplementedError 36 | 37 | @classmethod 38 | def name(cls) -> str: 39 | return cls.__name__.replace("State", "") 40 | 41 | @classmethod 42 | def register(cls, state_cls: type[FSMState]) -> None: 43 | cls._STATES[state_cls.name()] = state_cls 44 | 45 | @classmethod 46 | def factory(cls, context: TasksSync, generic_task: GenericTask) -> FSMState: 47 | return cls._STATES[generic_task.state](context=context, generic_task=generic_task) 48 | 49 | 50 | class StateHabiticaNew(FSMState): 51 | def next_state(self) -> None: 52 | self.generic_task.habitica_task_id = self.context.habitica.create_task( 53 | self.generic_task.content, self.generic_task.difficulty 54 | )["id"] 55 | self._set_state(StateHabiticaCreated) 56 | 57 | 58 | class StateHabiticaCreated(FSMState): 59 | def next_state(self) -> None: 60 | try: 61 | self.context.habitica.score_task(self.generic_task.get_habitica_task_id()) 62 | next_state: type[FSMState] = StateHabiticaFinished 63 | except HTTPError as ex: 64 | if ex.response is not None and ex.response.status_code == HTTPStatus.NOT_FOUND: 65 | next_state = StateHabiticaNew 66 | _LOGGER.warning(f"Habitica task '{self.generic_task.content}' not found. Re-setting state.") 67 | else: 68 | raise ex 69 | 70 | self._set_state(next_state) 71 | 72 | 73 | class StateHabiticaFinished(FSMState): 74 | def next_state(self) -> None: 75 | try: 76 | self.context.habitica.delete_task(self.generic_task.get_habitica_task_id()) 77 | except HTTPError as ex: 78 | if ex.response is not None and ex.response.status_code == HTTPStatus.NOT_FOUND: 79 | _LOGGER.warning(f"Habitica task '{self.generic_task.content}' not found.") 80 | else: 81 | raise ex 82 | 83 | self.context.delete_state(self.generic_task) 84 | 85 | 86 | FSMState.register(StateHabiticaNew) 87 | FSMState.register(StateHabiticaCreated) 88 | FSMState.register(StateHabiticaFinished) 89 | 90 | 91 | class TasksSync: # pylint: disable=too-few-public-methods 92 | """Class managing tasks synchronisation. 93 | 94 | Todoist API: https://developer.todoist.com/sync/v7/?python#overview 95 | Habitica API: https://habitica.com/apidoc 96 | """ 97 | 98 | def __init__(self): 99 | settings = get_settings() 100 | 101 | self.habitica = HabiticaAPI( 102 | HabiticaAPIHeaders(user_id=settings.habitica_user_id, api_key=settings.habitica_api_key) 103 | ) 104 | 105 | self._log = logging.getLogger(self.__class__.__name__) 106 | 107 | self._task_cache = TasksCache() 108 | self._todoist = TodoistAPI(settings.todoist_api_key, self._task_cache.last_sync_datetime_utc) 109 | self._todoist_user_id = settings.todoist_user_id 110 | 111 | self._sync_sleep: Final[DelayTimer] = DelayTimer( 112 | settings.sync_delay_seconds, "Next check in {delay:.0f} seconds." 113 | ) 114 | 115 | def run_forever(self) -> None: 116 | while True: 117 | try: 118 | self._task_cache.last_sync_datetime_utc = self._todoist.sync() 119 | self._next_tasks_state() 120 | except OSError as ex: 121 | self._log.error(f"Unexpected network error: {ex}") 122 | 123 | try: 124 | self._sync_sleep() 125 | except KeyboardInterrupt: 126 | break 127 | 128 | def set_state(self, state: FSMState) -> None: 129 | if state.generic_task.state != (new_state := state.name()): 130 | self._log.info(f"'{state.generic_task.content}' {state.generic_task.state} -> {new_state}") 131 | state.generic_task.state = new_state 132 | self._task_cache.save_task(state.generic_task) 133 | 134 | def delete_state(self, generic_task: GenericTask) -> None: 135 | self._log.info(f"'{generic_task.content}' done.") 136 | self._task_cache.delete_task(generic_task) 137 | 138 | @staticmethod 139 | def _get_task_difficulty(settings: Settings, labels: list[str], priority: TodoistPriority) -> HabiticaDifficulty: 140 | label_difficulties = [ 141 | settings.label_to_difficulty[label_lower] 142 | for label in labels 143 | if (label_lower := label.lower()) in settings.label_to_difficulty 144 | ] 145 | if label_difficulties: 146 | return max(label_difficulties) 147 | return settings.priority_to_difficulty[priority] 148 | 149 | @property 150 | def todoist_user_id(self) -> str | None: 151 | return self._todoist_user_id 152 | 153 | def _next_tasks_state(self) -> None: 154 | for todoist_completed_task in self._todoist.iter_pop_newly_completed_tasks(): # pylint: disable=no-member 155 | generic_task = GenericTask( 156 | content=todoist_completed_task.item_object.content, 157 | difficulty=self._get_task_difficulty( 158 | get_settings(), 159 | todoist_completed_task.item_object.labels, 160 | TodoistPriority(todoist_completed_task.item_object.priority), 161 | ), 162 | state=StateHabiticaNew.name(), 163 | ) 164 | self._task_cache.save_task(generic_task) 165 | self._log.info(f"'{generic_task.content}' -> {generic_task.state}") 166 | 167 | for generic_task in self._task_cache.in_progress_tasks(): 168 | try: 169 | state = FSMState.factory(self, generic_task) 170 | state.next_state() 171 | except OSError as ex: 172 | self._log.error(f"Unexpected network error when processing task '{generic_task.content}': {str(ex)}") 173 | 174 | 175 | if __name__ == "__main__": 176 | logging.basicConfig(level=logging.INFO, format="%(asctime)s (%(name)s) [%(levelname)s]: %(message)s") 177 | 178 | TasksSync().run_forever() 179 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | gh: circleci/github-cli@1.0.4 5 | 6 | parameters: 7 | project_name: 8 | type: string 9 | default: todoist-habitica-sync 10 | working_directory: 11 | type: string 12 | default: ~/todoist-habitica-sync 13 | python_version: 14 | type: string 15 | default: "3.12" 16 | python_version_full: 17 | type: string 18 | default: "3.12.4" 19 | cache_version: 20 | type: string 21 | default: "3" 22 | git_trunk_branch: 23 | type: string 24 | default: "main" 25 | command_prefix: 26 | type: string 27 | default: "poetry run delfino" 28 | dockerhub_username: 29 | type: string 30 | default: "radeklat" 31 | 32 | jobs: # A basic unit of work in a run 33 | test: 34 | docker: 35 | - image: cimg/python:<< pipeline.parameters.python_version_full >> 36 | environment: 37 | PIPENV_VENV_IN_PROJECT: "true" 38 | working_directory: << pipeline.parameters.working_directory >> 39 | steps: 40 | - checkout 41 | - restore_cache: 42 | key: << pipeline.parameters.cache_version >>-<< pipeline.parameters.project_name >>-<< pipeline.parameters.python_version_full >>-{{ checksum "poetry.lock" }} 43 | - run: 44 | name: Install dev libraries 45 | command: | 46 | pip install --upgrade pip poetry 47 | poetry install --no-ansi --no-root 48 | - run: 49 | name: Pre-commit checks 50 | command: << pipeline.parameters.command_prefix >> pre-commit run -a 51 | - run: 52 | name: Verify checks 53 | command: << pipeline.parameters.command_prefix >> verify 54 | - save_cache: 55 | key: << pipeline.parameters.cache_version >>-<< pipeline.parameters.project_name >>-<< pipeline.parameters.python_version_full >>-{{ checksum "poetry.lock" }} 56 | paths: 57 | - ".venv" 58 | - "~/.pyenv/versions/<< pipeline.parameters.python_version_full >>/lib/python<< pipeline.parameters.python_version >>/site-packages" 59 | - run: 60 | name: Build coverage report 61 | command: << pipeline.parameters.command_prefix >> coverage-report 62 | - run: 63 | name: Upload coverage reports to Codecov 64 | command: | 65 | [[ "${CIRCLE_BRANCH}" == "<< pipeline.parameters.git_trunk_branch >>" ]] && BASE_COMMIT_SHA=$(git rev-parse HEAD~1) || BASE_COMMIT_SHA=$(git merge-base ${CIRCLE_BRANCH} origin/<< pipeline.parameters.git_trunk_branch >>) 66 | bash <(curl -s https://codecov.io/bash) -N ${BASE_COMMIT_SHA} -f reports/coverage-unit.xml -F total,unit_tests && 67 | bash <(curl -s https://codecov.io/bash) -N ${BASE_COMMIT_SHA} -f reports/coverage-integration.xml -F total,integration_tests 68 | - store_artifacts: 69 | path: reports/coverage-report 70 | - store_test_results: 71 | path: reports 72 | 73 | build_and_push: 74 | machine: 75 | image: ubuntu-2204:current # https://circleci.com/developer/machine/image/ubuntu-2204 76 | steps: 77 | - checkout 78 | - restore_cache: 79 | key: << pipeline.parameters.cache_version >>-docker-build-<< pipeline.parameters.project_name >>-<< pipeline.parameters.python_version_full >>-{{ checksum "poetry.lock" }} 80 | - run: 81 | name: Install and activate required Python version 82 | command: | 83 | export VERSION="<< pipeline.parameters.python_version_full >>" 84 | if [[ ! -d "/opt/circleci/.pyenv/versions/${VERSION}" ]]; then 85 | echo "Python ${VERSION} not installed." 86 | 87 | if [[ -z "$(pyenv install --list | grep -q ${VERSION})" ]]; then 88 | echo "Python ${VERSION} not available. Trying a pyenv update ..." 89 | cd /opt/circleci/.pyenv 90 | 91 | # Fix permissions 92 | sudo chown -R circleci:circleci . 93 | 94 | # Fix remote 95 | git remote remove origin 96 | git remote add origin https://github.com/pyenv/pyenv.git 97 | git fetch --all 98 | 99 | # Update 100 | git checkout master 101 | git pull 102 | cd - 103 | fi 104 | pyenv install ${VERSION} 105 | else 106 | echo "Python ${VERSION} already installed." 107 | fi 108 | pyenv global ${VERSION} 109 | - run: 110 | name: Install poetry 111 | command: pip install poetry 112 | - run: 113 | name: Install dev libraries 114 | command: poetry install --no-ansi --no-root 115 | - run: 116 | name: Check if build needed 117 | command: | 118 | export PATH="/home/circleci/.local/bin:$PATH" 119 | export DOCKER_CLI_EXPERIMENTAL=enabled 120 | IMAGE_VERSION="$(poetry version -s)" 121 | tag_exists="$(docker manifest inspect << pipeline.parameters.dockerhub_username >>/<< pipeline.parameters.project_name >>:$IMAGE_VERSION >/dev/null 2>&1; echo $?)" 122 | if [[ $tag_exists -eq 0 ]]; then 123 | echo "Image tag '$IMAGE_VERSION' already exists in the repository. Skipping job." 124 | circleci-agent step halt 125 | else 126 | echo "Image tag '$IMAGE_VERSION' doesn't exist in the repository. Running build." 127 | fi 128 | - save_cache: 129 | key: << pipeline.parameters.cache_version >>-docker-build-<< pipeline.parameters.project_name >>-<< pipeline.parameters.python_version_full >>-{{ checksum "poetry.lock" }} 130 | paths: 131 | - "/opt/circleci/.pyenv/versions/<< pipeline.parameters.python_version_full >>" 132 | - run: 133 | name: Create and use a new docker build driver 134 | command: | 135 | docker buildx create --use 136 | - run: 137 | name: Build and push docker image 138 | command: << pipeline.parameters.command_prefix >> docker-build --push --serialized 139 | release: 140 | working_directory: << pipeline.parameters.working_directory >> 141 | docker: 142 | - image: cimg/python:<< pipeline.parameters.python_version_full >> 143 | steps: 144 | - checkout 145 | - restore_cache: 146 | key: << pipeline.parameters.cache_version >>-<< pipeline.parameters.project_name >>-<< pipeline.parameters.python_version_full >>-{{ checksum "poetry.lock" }} 147 | - gh/setup 148 | - run: 149 | name: Install poetry 150 | command: pip install poetry 151 | - run: 152 | name: Check if current version has been released 153 | command: | 154 | VERSION=$(poetry version -s) 155 | if [[ $(gh release view $VERSION >/dev/null 2>&1; echo $?) -eq 0 ]]; then 156 | echo "Tag '$VERSION' already exists. Skipping." 157 | circleci-agent step halt 158 | fi 159 | - run: 160 | name: Create a release 161 | command: | 162 | gh release create $(poetry version -s) -F CHANGELOG.md 163 | 164 | workflows: 165 | version: 2 166 | all_pipelines: 167 | jobs: 168 | - test: 169 | context: << pipeline.parameters.project_name >> 170 | name: Tests 171 | - build_and_push: 172 | name: Build and push docker image 173 | context: 174 | - dockerhub 175 | requires: 176 | - Tests 177 | filters: 178 | branches: 179 | only: << pipeline.parameters.git_trunk_branch >> 180 | - release: 181 | context: 182 | - github 183 | requires: 184 | - Build and push docker image 185 | filters: 186 | branches: 187 | only: << pipeline.parameters.git_trunk_branch >> 188 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | Types of changes are: 7 | 8 | - **Breaking changes** for breaking changes. 9 | - **Features** for new features or changes in existing functionality. 10 | - **Fixes** for any bug fixes. 11 | - **Deprecated** for soon-to-be removed features. 12 | 13 | ## [Unreleased] 14 | 15 | ## [4.0.1] - 2025-03-19 16 | 17 | - Dependencies update. 18 | - Fix magic constants. 19 | 20 | ## [4.0.0] - 2025-03-18 21 | 22 | ### Breaking changes 23 | 24 | - The default sync cache file has changed to `sync_cache.sqlite`, so the previously used `sync_cache.json` will not be used anymore and can be deleted. However, if you set the `SYNC_CACHE_FILE` environment variable, the old file will be used and a manual [cache reset](README.md#resetting-sync-cache) before upgrading to this version is needed. 25 | 26 | ### Features 27 | 28 | - The Todoist synchronisation logic has been rewritten to use the `completed/get_all` endpoint instead of the `sync` endpoint of the Todoist API. This endpoint returns reliably all completed tasks, including recurring tasks. It also returns only completed tasks since last sync, making it much faster. In turn, it allowed greatly simplifying the sync logic. 29 | - The sync cache has been migrated from tinydb to sqlite. This should improve performance. 30 | 31 | 32 | ## [3.4.1] - 2024-12-26 33 | 34 | ### Fixes 35 | 36 | - Make the configuration options [`LABEL_TO_DIFFICULTY`](README.md#environment-variables) and [`PRIORITY_TO_DIFFICULTY`](README.md#environment-variables) more tolerant to different value types. 37 | 38 | ## [3.4.0] - 2024-12-21 39 | 40 | ### Features 41 | 42 | - Added configuration option [`LABEL_TO_DIFFICULTY`](README.md#environment-variables) to define how Todoist labels map to Habitica difficulties. If a task has no matching label, the `PRIORITY_TO_DIFFICULTY` mapping is used. If a task has multiple labels, the highest difficulty is used. 43 | 44 | ## [3.3.0] - 2024-12-15 45 | 46 | ### Features 47 | 48 | - Added configuration option [`PRIORITY_TO_DIFFICULTY`](README.md#environment-variables) to define how Todoist priorities map to Habitica difficulties. The default has changed from: 49 | 50 | p1 -> Medium, p2 -> Hard, p3 -> Easy, p4 -> Trivial 51 | 52 | to: 53 | 54 | p1 -> Hard, p2 -> Medium, p3 -> Easy, p4 -> Trivial 55 | 56 | ## [3.2.1] - 2024-12-15 57 | 58 | ### Fixes 59 | 60 | - Dependencies update. 61 | 62 | ## [3.2.0] - 2024-03-14 63 | 64 | ### Features 65 | 66 | - Improve logging of authentication errors. 67 | 68 | ## [3.1.0] - 2024-03-06 69 | 70 | ### Features 71 | 72 | - Log path to sync cache file on app startup. 73 | 74 | ### Fixes 75 | 76 | - Path to sync cache in `docker-compose.yml` 77 | - Improved iteration over Habitica states (less waiting). 78 | 79 | ## [3.0.2] - 2024-02-17 80 | 81 | ### Fixes 82 | 83 | - Incorrect awarding of points for rescheduled recurring tasks. There is no difference between rescheduled and completed once other than completed being scheduled to the future. Therefore, only recurring tasks rescheduled to the future will be awarded points. This can still be a false positive, if a recurring task is scheduled to the future manually. 84 | 85 | ## [3.0.1] - 2023-11-13 86 | 87 | ### Fixes 88 | 89 | - Incorrect transition. 90 | 91 | ## [3.0.0] - 2023-11-13 92 | 93 | ### Breaking changes 94 | 95 | - Rewritten the state logic to the state design pattern for easier understanding and extensibility. This has resulted in changing how the data is stored in the sync cache. Please [reset the cache](README.md#resetting-sync-cache) before upgrading to this version. 96 | 97 | ### Fixes 98 | 99 | - Tasks deleted in habitica being stuck in a loop and logging errors. 100 | - Recurring tasks completed forever in Todoist being stuck in a loop. 101 | 102 | ## [2.1.10] - 2023-11-01 103 | 104 | ### Fixes 105 | 106 | - Upgrade to Python 3.12.0. 107 | - Upgrade to `pydantic` 2.x. 108 | - Switch to `settings-doc` in `pre-commit` config. 109 | 110 | ## [2.1.9] - 2023-10-31 111 | 112 | ### Fixes 113 | 114 | - Move verbose logging of unexpected HTTP errors. 115 | 116 | ## [2.1.8] - 2023-09-14 117 | 118 | ### Fixes 119 | 120 | - Dependencies update. 121 | - Better handling of invalid Todoist API token. 122 | 123 | ## [2.1.7] - 2022-12-30 124 | 125 | ### Fixes 126 | 127 | - Downgrade `cryptography` to allow build for armv7. 128 | 129 | ## [2.1.6] - 2022-12-29 130 | 131 | ### Fixes 132 | 133 | - Dependencies update 134 | 135 | ## [2.1.5] - 2022-12-09 136 | 137 | ### Fixes 138 | 139 | - Dependencies update 140 | 141 | ## [2.1.4] - 2022-12-03 142 | 143 | ### Fixes 144 | 145 | - Recurring task stuck in a loop when permanently finished. 146 | 147 | ## [2.1.3] - 2022-11-03 148 | 149 | ### Fixes 150 | 151 | - Drop deprecated [`todoist-python`](https://github.com/Doist/todoist-python) library to communicate with the [v8 Sync API](https://developer.todoist.com/sync/v8, which has been also terminated. This has been replaced with [a custom implementation](src/todoist_api.py) of the [v9 Sync API](https://developer.todoist.com/sync/v9). 152 | 153 | ## [2.1.2] - 2022-07-06 154 | 155 | ### Fixes 156 | 157 | - Dependencies update, including an upgrade from Python 3.10.4 to 3.10.5 158 | 159 | ## [2.1.1] - 2022-03-06 160 | 161 | ### Fixes 162 | 163 | - Dependencies update, including an upgrade from Python 3.10.2 to 3.10.4 164 | 165 | ## [2.1.0] - 2022-03-06 166 | 167 | ### Features 168 | 169 | - New optional configuration option `todoist_user_id`. When set, tasks completed in project will score points only if they were assigned to you or no one, not when assigned to someone else. Who completed the task is not taken into account. 170 | 171 | ### Fixes 172 | 173 | - Delay between each Habitica API action has been increased from 0.5s to 30s, as mandated by the official documentation. 174 | - `x-client` HTTP header is being sent to notify the API of the author of the tool, as mandated by the official documentation. 175 | 176 | ## [2.0.0] - 2022-01-30 177 | 178 | ### Breaking changes 179 | 180 | - Recurring Todoist tasks are counted on every completion, not just the fist one. Please [reset the cache](README.md#resetting-sync-cache) as this fix doesn't work for already cached tasks. 181 | 182 | ## [1.2.0] - 2022-01-30 183 | 184 | ### Features 185 | 186 | - Upgrade to Python 3.10 187 | 188 | ### Fixes 189 | 190 | - Update library dependencies. 191 | 192 | ## [1.1.3] - 2022-01-25 193 | 194 | ### Fixes 195 | 196 | - Update library dependencies. 197 | 198 | ## [1.1.2] - 2021-11-29 199 | 200 | ### Fixes 201 | 202 | - Update library dependencies. 203 | 204 | ## [1.1.1] - 2021-10-31 205 | 206 | ### Fixes 207 | 208 | - Change `Dockerfile` to use Python version from `pyproject.toml`. 209 | - Updates build process to build docker images for multiple architectures. 210 | - Load environment variables from a `.env` file in the compose file. 211 | 212 | ## [1.1.0] - 2021-03-20 213 | 214 | ### Features 215 | 216 | - Automatically create nested path for sync cache. 217 | - Guide for running from a docker image. 218 | - Guide for running via docker-compose and a compose file. 219 | 220 | ### Fixes 221 | 222 | - Virtualenv creation in docker image. 223 | - Move sync cache file into a folder to allow easier mounting in docker images. 224 | 225 | ## [1.0.0] - 2019-10-06 226 | 227 | ### Features 228 | 229 | - Initial release 230 | 231 | [Unreleased]: https://github.com/radeklat/todoist-habitica-sync/compare/4.0.1...HEAD 232 | [4.0.1]: https://github.com/radeklat/todoist-habitica-sync/compare/4.0.0...4.0.1 233 | [4.0.0]: https://github.com/radeklat/todoist-habitica-sync/compare/3.4.1...4.0.0 234 | [3.4.1]: https://github.com/radeklat/todoist-habitica-sync/compare/3.4.0...3.4.1 235 | [3.4.0]: https://github.com/radeklat/todoist-habitica-sync/compare/3.3.0...3.4.0 236 | [3.3.0]: https://github.com/radeklat/todoist-habitica-sync/compare/3.2.1...3.3.0 237 | [3.2.1]: https://github.com/radeklat/todoist-habitica-sync/compare/3.2.0...3.2.1 238 | [3.2.0]: https://github.com/radeklat/todoist-habitica-sync/compare/3.1.0...3.2.0 239 | [3.1.0]: https://github.com/radeklat/todoist-habitica-sync/compare/3.0.2...3.1.0 240 | [3.0.2]: https://github.com/radeklat/todoist-habitica-sync/compare/3.0.1...3.0.2 241 | [3.0.1]: https://github.com/radeklat/todoist-habitica-sync/compare/3.0.0...3.0.1 242 | [3.0.0]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.10...3.0.0 243 | [2.1.10]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.9...2.1.10 244 | [2.1.9]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.8...2.1.9 245 | [2.1.8]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.7...2.1.8 246 | [2.1.7]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.6...2.1.7 247 | [2.1.6]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.5...2.1.6 248 | [2.1.5]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.4...2.1.5 249 | [2.1.4]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.3...2.1.4 250 | [2.1.3]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.2...2.1.3 251 | [2.1.2]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.1...2.1.2 252 | [2.1.1]: https://github.com/radeklat/todoist-habitica-sync/compare/2.1.0...2.1.1 253 | [2.1.0]: https://github.com/radeklat/todoist-habitica-sync/compare/2.0.0...2.1.0 254 | [2.0.0]: https://github.com/radeklat/todoist-habitica-sync/compare/1.2.0...2.0.0 255 | [1.2.0]: https://github.com/radeklat/todoist-habitica-sync/compare/1.1.3...1.2.0 256 | [1.1.3]: https://github.com/radeklat/todoist-habitica-sync/compare/1.1.2...1.1.3 257 | [1.1.2]: https://github.com/radeklat/todoist-habitica-sync/compare/1.1.1...1.1.2 258 | [1.1.1]: https://github.com/radeklat/todoist-habitica-sync/compare/1.1.0...1.1.1 259 | [1.1.0]: https://github.com/radeklat/todoist-habitica-sync/compare/1.0.0...1.1.0 260 | [1.0.0]: https://github.com/radeklat/todoist-habitica-sync/compare/initial...1.0.0 261 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

:ballot_box_with_check::crossed_swords:   Todoist ⇒ Habitica Sync   :crossed_swords::ballot_box_with_check:

2 |

One way synchronisation from Todoist to Habitica

3 | 4 |

5 | CircleCI 6 | Codecov 7 | GitHub tag (latest SemVer) 8 | Maintenance 9 | GitHub last commit 10 | Docker Pulls 11 | Docker Image Version (latest semver) 12 | Docker Image Size (latest semver) 13 |

14 | 15 | Because if you want to be productive, you should not track tasks in two places. Habitica is great for tracking habits, Todoist for tracking TODOs. Now you can get the best of both. 16 | 17 | ## How it works 18 | 19 | Tasks are not added immediately. Only when you finish a task in Todoist, new task will be created in Habitica, finished and immediately deleted. So you won't see any Todoist tasks in Habitica but still get the rewards. 20 | 21 | # Features 22 | 23 | * Scores points in Habitica when Todoist task is finished. 24 | * Works for repeated tasks in Todoist, as long as the date string contains `every`. 25 | 26 | # Installation 27 | 28 | ## As a script 29 | 30 | 1. Open terminal 31 | 2. Make sure [`git`](https://github.com/git-guides/install-git) is installed: 32 | ```shell script 33 | git --version 34 | ``` 35 | The output should look something like: 36 | ```text 37 | git version 2.25.1 38 | ``` 39 | 3. Make sure Python 3.12.x is installed: 40 | ```shell script 41 | python --version 42 | ``` 43 | The output should look something like: 44 | ```text 45 | Python 3.12.0 46 | ``` 47 | * If it shows `2.7.x` instead, try `python3` instead and use it in the rest of the guide. 48 | * If it shows `3.11.x` or lower, use [`pyenv`](https://github.com/pyenv/pyenv#installation) to install a higher version of Python on your system. 49 | 4. Make sure [`poetry`](https://python-poetry.org/docs/#installation) is installed: 50 | ```shell script 51 | poetry --version 52 | ``` 53 | The output should look something like: 54 | ``` 55 | Poetry (version 1.7.1) 56 | 5. Clone this repository: 57 | ```shell script 58 | git clone https://github.com/radeklat/todoist-habitica-sync.git 59 | cd todoist-habitica-sync 60 | ``` 61 | 6. Copy the `.env.template` file into `.env` file: 62 | ```shell script 63 | cp .env.template .env 64 | ``` 65 | 7. Edit the `.env` file, fill all missing values and/or change existing ones to suit your needs. 66 | 8. Install all application dependencies: 67 | ```shell script 68 | poetry install --no-root --without=dev 69 | ``` 70 | 9. Run the app: 71 | ```shell script 72 | poetry run python src/main.py 73 | ``` 74 | 75 | ## As a docker container 76 | 77 | 1. Open terminal 78 | 2. Make sure [`docker`](https://docs.docker.com/get-docker/) is installed: 79 | ```shell script 80 | docker --version 81 | ``` 82 | The output should look something like: 83 | ```text 84 | Docker version 20.10.5, build 55c4c88 85 | ``` 86 | 3. Create a base folder structure for the service: 87 | ```shell script 88 | mkdir -p todoist-habitica-sync/.sync_cache 89 | cd todoist-habitica-sync 90 | chmod 0777 .sync_cache 91 | # Download .env file template 92 | curl https://raw.githubusercontent.com/radeklat/todoist-habitica-sync/master/.env.template --output .env 93 | ``` 94 | 4. Edit the `.env` file and fill the missing details. 95 | 5. Run the container: 96 | ```shell script 97 | docker run \ 98 | --pull always --rm --name todoist-habitica-sync \ 99 | --env-file=.env \ 100 | -v $(pwd)/.sync_cache:/app/.sync_cache \ 101 | radeklat/todoist-habitica-sync:latest 102 | ``` 103 | This configuration will run the service in the foreground (you need to keep the terminal open) and always use the latest version. 104 | * Change `latest` to [a specific version](https://hub.docker.com/repository/registry-1.docker.io/radeklat/todoist-habitica-sync/tags) if you don't always want the latest version or remove the `--pull always` flag to not update. 105 | 106 | 107 | Add `--detach` flag to run in the background. You can close the terminal, but it will not start on system start up. 108 | * To see the log of the service running in the background, run `docker logs todoist-habitica-sync` 109 | * To stop the service, run `docker container stop todoist-habitica-sync` 110 | 111 | ## As a service 112 | 113 | You can use the above mentioned docker image to run the sync as a service on server or even your local machine. The simples way is to use docker compose: 114 | 115 | 1. Make sure you have [`docker`](https://docs.docker.com/get-docker/) installed: 116 | ```shell script 117 | docker --version 118 | docker compose version 119 | ``` 120 | The output should look something like: 121 | ```text 122 | Docker version 23.0.2, build 569dd73 123 | Docker Compose version v2.17.2 124 | ``` 125 | 2. Download the example compose file: 126 | ```shell script 127 | mkdir -p todoist-habitica-sync/.sync_cache 128 | cd todoist-habitica-sync 129 | chmod 0777 .sync_cache 130 | ``` 131 | 3. Download a compose file and an example .env file: 132 | ```shell script 133 | BASE_URL=https://raw.githubusercontent.com/radeklat/todoist-habitica-sync/master 134 | curl $BASE_URL/.env.template --output .env 135 | curl $BASE_URL/docker-compose.yml -O 136 | ``` 137 | 4. Edit the `.env` file and fill the missing details. 138 | 5. Run the service: 139 | ```shell script 140 | docker compose up 141 | ``` 142 | This command will run the service in the foreground (you need to keep the terminal open) and always use the latest version. 143 | * Change `latest` to [a specific version](https://hub.docker.com/repository/registry-1.docker.io/radeklat/todoist-habitica-sync/tags) if you don't always want the latest version. 144 | 145 | 146 | Add `--detach` flag to run in the background. You can close the terminal. The service should start on system start up. 147 | * To see the log of the service running in the background, run `docker-compose logs todoist-habitica-sync` 148 | * To stop the service, run `docker-compose stop todoist-habitica-sync` 149 | 150 | # Update 151 | 152 | ## As a script 153 | 154 | 1. Open terminal 155 | 2. Navigate to the project repository: 156 | ```shell script 157 | cd todoist-habitica-sync 158 | ``` 159 | 3. Update the source code: 160 | ```shell script 161 | git pull 162 | ``` 163 | 164 | 4. Update application dependencies: 165 | ```shell script 166 | poetry install --no-dev 167 | ``` 168 | 5. Run the application again. 169 | 170 | ## From docker-compose 171 | 172 | If you used the `latest` tag, run: 173 | 174 | ```shell script 175 | docker compose stop # stop the container 176 | docker compose pull todoist-habitica-sync # pull a new image 177 | docker compose up -d --force-recreate # re-create container with the new image 178 | docker image prune -f # clean up unused images 179 | ``` 180 | 181 | # Environment variables 182 | 183 | ## `TODOIST_USER_ID` 184 | 185 | *Optional*, default value: `None` 186 | 187 | See "user_id" mentioned in a link under "Calendar Subscription URL" at https://todoist.com/prefs/integrations. Needed only for shared projects to score points for tasks owned by you. 188 | 189 | ## `TODOIST_API_KEY` 190 | 191 | **Required** 192 | 193 | See https://todoist.com/prefs/integrations under "API token". 194 | 195 | ## `HABITICA_USER_ID` 196 | 197 | **Required** 198 | 199 | See https://habitica.com/user/settings/api under "User ID". 200 | 201 | ## `HABITICA_API_KEY` 202 | 203 | **Required** 204 | 205 | See https://habitica.com/user/settings/api under "API Token", the "Show API Token" button. 206 | 207 | ## `SYNC_DELAY_MINUTES` 208 | 209 | *Optional*, default value: `1` 210 | 211 | Repeat sync automatically after N minutes. 212 | 213 | ## `DATABASE_FILE` 214 | 215 | *Optional*, default value: `.sync_cache/sync_cache.sqlite` 216 | 217 | Where to store synchronisation details. No need to change. 218 | 219 | ## `PRIORITY_TO_DIFFICULTY` 220 | 221 | *Optional*, default value: `{'P1': 'HARD', 'P2': 'MEDIUM', 'P3': 'EASY', 'P4': 'TRIVIAL'}` 222 | 223 | Defines how Todoist priorities map to Habitica difficulties. Keys/values are case-insensitive and can be both names or numerical values defines by the APIs. See https://habitica.com/apidoc/#api-Task-CreateUserTasks and https://developer.todoist.com/sync/v9/#items for numerical values definitions. 224 | 225 | ## `LABEL_TO_DIFFICULTY` 226 | 227 | *Optional* 228 | 229 | Defines how Todoist labels map to Habitica difficulties. Keys are case-insensitive. See https://habitica.com/apidoc/#api-Task-CreateUserTasks for difficulty values. If a task has no matching label, the `priority_to_difficulty` mapping is used. If a task has multiple labels, the highest difficulty is used. 230 | 231 | 232 | # Resetting sync cache 233 | 234 | Sometimes certain changes require to reset the sync cache. The cache holds state information only to allow recovery after an unexpected termination of the program. So it is not needed in between restarts and can be safely removed. 235 | 236 | To reset the cache: 237 | 1. Stop the application. 238 | 2. Remove the `.sync_cache/sync_cache.sqlite` file or any other location given in the [`DATABASE_FILE`](#database_file) config option. 239 | 3. Optionally, [update the application](#update). 240 | 4. Start to application again. 241 | 242 | # Planned work 243 | 244 | * Synchronise overdue task to cause damage in habitica 245 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | # pylintrc... 2 | # - Based on Google's https://github.com/google/seq2seq/blob/master/pylintrc 3 | # - And combined with https://github.com/cookpad/global-style-guides/blob/master/pylintrc 4 | # - And a few checks disabled (to be reintroduced gradually) 5 | 6 | [MASTER] 7 | 8 | # Specify a configuration file. 9 | #rcfile= 10 | 11 | # Python code to execute, usually for sys.path manipulation such as 12 | # pygtk.require(). 13 | #init-hook= 14 | 15 | # Add files or directories to the blacklist. They should be base names, not 16 | # paths. 17 | ignore=CVS 18 | 19 | # Add files or directories matching the regex patterns to the blacklist. The 20 | # regex matches against base names, not paths. 21 | ignore-patterns= 22 | 23 | # Pickle collected data for later comparisons. 24 | persistent=yes 25 | 26 | # List of plugins (as comma separated values of python modules names) to load, 27 | # usually to register additional checkers. 28 | load-plugins=pylint.extensions.docparams,pylint.extensions.mccabe 29 | 30 | # Use multiple processes to speed up Pylint. 31 | jobs=1 32 | 33 | # Allow loading of arbitrary C extensions. Extensions are imported into the 34 | # active Python interpreter and may run arbitrary code. 35 | unsafe-load-any-extension=no 36 | 37 | # A comma-separated list of package or module names from where C extensions may 38 | # be loaded. Extensions are loading into the active Python interpreter and may 39 | # run arbitrary code 40 | extension-pkg-whitelist=numpy,pydantic,ujson,Stemmer 41 | 42 | [MESSAGES CONTROL] 43 | 44 | # Only show warnings with the listed confidence levels. Leave empty to show 45 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 46 | confidence= 47 | 48 | # Enable the message, report, category or checker with the given id(s). You can 49 | # either give multiple identifier separated by comma (,) or put this option 50 | # multiple time (only on the command line, not in the configuration file where 51 | # it should appear only once). See also the "--disable" option for examples. 52 | #enable= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once).You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use"--disable=all --enable=classes 62 | # --disable=W" 63 | disable=arguments-differ, 64 | arguments-out-of-order, 65 | locally-disabled, 66 | missing-class-docstring, 67 | missing-docstring, 68 | missing-function-docstring, 69 | missing-raises-doc, 70 | missing-return-doc, 71 | suppressed-message, 72 | useless-suppression, 73 | wildcard-import, 74 | logging-fstring-interpolation, 75 | 76 | [REPORTS] 77 | 78 | # Set the output format. Available formats are text, parseable, colorized, msvs 79 | # (visual studio) and html. You can also give a reporter class, eg 80 | # mypackage.mymodule.MyReporterClass. 81 | output-format=colorized 82 | 83 | # Tells whether to display a full report or only the messages 84 | reports=no 85 | 86 | # Python expression which should return a note less than 10 (10 is the highest 87 | # note). You have access to the variables errors warning, statement which 88 | # respectively contain the number of errors / warnings messages and the total 89 | # number of statements analyzed. This is used by the global evaluation report 90 | # (RP0004). 91 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 92 | 93 | # Template used to display messages. This is a python new-style format string 94 | # used to format the message information. See doc for all details 95 | msg-template={path}:{line}:{column} : {msg} ({symbol}, {msg_id}) 96 | 97 | 98 | [BASIC] 99 | 100 | # Good variable names which should always be accepted, separated by a comma 101 | good-names=i,j,k,ex,Run,_ 102 | 103 | # Bad variable names which should always be refused, separated by a comma 104 | bad-names=foo,bar,baz,toto,tutu,tata 105 | 106 | # Colon-delimited sets of names that determine each other's naming style when 107 | # the name regexes allow several styles. 108 | name-group= 109 | 110 | # Include a hint for the correct naming format with invalid-name 111 | include-naming-hint=no 112 | 113 | # List of decorators that produce properties, such as abc.abstractproperty. Add 114 | # to this list to register other decorators that produce valid properties. 115 | property-classes=abc.abstractproperty 116 | 117 | # Regular expression matching correct variable names 118 | variable-rgx=[a-z_][a-z0-9_]{2,35}$ 119 | 120 | # Regular expression matching correct class attribute names 121 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,45}|(__.*__)|id)$ 122 | 123 | # Regular expression matching correct argument names 124 | argument-rgx=[a-z_][a-z0-9_]{2,35}$ 125 | 126 | # Regular expression matching correct module names 127 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 128 | 129 | # Regular expression matching correct constant names 130 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 131 | 132 | # Regular expression matching correct inline iteration names 133 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 134 | 135 | # Regular expression matching correct method names 136 | method-rgx=[a-z_][a-z0-9_]{2,40}$ 137 | 138 | # Regular expression matching correct function names 139 | function-rgx=[a-z_][a-z0-9_]{2,40}$ 140 | 141 | # Regular expression matching correct attribute names 142 | attr-rgx=[A-Za-z_][A-Za-z0-9_]{2,45}$ 143 | 144 | # Regular expression matching correct class names 145 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 146 | 147 | # Regular expression which should only match function or class names that do 148 | # not require a docstring. 149 | no-docstring-rgx=^(test_|__init__) 150 | 151 | # Minimum line length for functions/classes that require docstrings, shorter 152 | # ones are exempt. 153 | docstring-min-length=-1 154 | 155 | # Docstring-vs-method-signature inspection. See `pylint.extensions.docparams` plugin. We also disable 156 | # "missing-raises-doc" in disables= 157 | accept-no-param-doc=yes 158 | accept-no-raise-doc=yes 159 | accept-no-return-doc=yes 160 | accept-no-yields-doc=yes 161 | default-docstring-type=google 162 | 163 | [ELIF] 164 | 165 | # Maximum number of nested blocks for function / method body 166 | max-nested-blocks=5 167 | 168 | 169 | [FORMAT] 170 | 171 | # Maximum number of characters on a single line. 172 | max-line-length=120 173 | 174 | # Regexp for a line that is allowed to be longer than the limit. 175 | ignore-long-lines=^\s*(# )??$ 176 | 177 | # Allow the body of an if to be on the same line as the test if there is no 178 | # else. 179 | single-line-if-stmt=y 180 | 181 | # Maximum number of lines in a module 182 | max-module-lines=1000 183 | 184 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 185 | # tab). 186 | indent-string=' ' 187 | 188 | # Number of spaces of indent required inside a hanging or continued line. 189 | indent-after-paren=4 190 | 191 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 192 | expected-line-ending-format= 193 | 194 | 195 | [LOGGING] 196 | 197 | # Logging modules to check that the string format arguments are in logging 198 | # function parameter format 199 | logging-modules=logging 200 | 201 | 202 | [MISCELLANEOUS] 203 | 204 | # List of note tags to take in consideration, separated by a comma. 205 | notes=FIXME,XXX 206 | 207 | 208 | [SIMILARITIES] 209 | 210 | # Minimum lines number of a similarity. 211 | min-similarity-lines=10 212 | 213 | # Ignore comments when computing similarities. 214 | ignore-comments=yes 215 | 216 | # Ignore docstrings when computing similarities. 217 | ignore-docstrings=yes 218 | 219 | # Ignore imports when computing similarities. 220 | ignore-imports=yes 221 | 222 | 223 | [SPELLING] 224 | 225 | # Spelling dictionary name. Available dictionaries: none. To make it working 226 | # install python-enchant package. 227 | spelling-dict= 228 | 229 | # List of comma separated words that should not be checked. 230 | spelling-ignore-words= 231 | 232 | # A path to a file that contains private dictionary; one word per line. 233 | spelling-private-dict-file= 234 | 235 | # Tells whether to store unknown words to indicated private dictionary in 236 | # --spelling-private-dict-file option instead of raising a message. 237 | spelling-store-unknown-words=no 238 | 239 | 240 | [TYPECHECK] 241 | 242 | # Tells whether missing members accessed in mixin class should be ignored. A 243 | # mixin class is detected if its name ends with "mixin" (case insensitive). 244 | ignore-mixin-members=yes 245 | 246 | # List of module names for which member attributes should not be checked 247 | # (useful for modules/projects where namespaces are manipulated during runtime 248 | # and thus existing member attributes cannot be deduced by static analysis. It 249 | # supports qualified module names, as well as Unix pattern matching. 250 | ignored-modules= 251 | 252 | # List of class names for which member attributes should not be checked (useful 253 | # for classes with dynamically set attributes). This supports the use of 254 | # qualified names. 255 | ignored-classes=optparse.Values,thread._local,_thread._local,matplotlib.cm,tensorflow.python,tensorflow,tensorflow.train.Example,RunOptions 256 | 257 | # List of members which are set dynamically and missed by pylint inference 258 | # system, and so shouldn't trigger E1101 when accessed. Python regular 259 | # expressions are accepted. 260 | generated-members=set_shape,np.float32 261 | 262 | # List of decorators that produce context managers, such as 263 | # contextlib.contextmanager. Add to this list to register other decorators that 264 | # produce valid context managers. 265 | contextmanager-decorators=contextlib.contextmanager 266 | 267 | 268 | [VARIABLES] 269 | 270 | # Tells whether we should check for unused import in __init__ files. 271 | init-import=no 272 | 273 | # A regular expression matching the name of dummy variables (i.e. expectedly 274 | # not used). 275 | dummy-variables-rgx=(_+[a-zA-Z0-9_]*?$)|dummy 276 | 277 | # List of additional names supposed to be defined in builtins. Remember that 278 | # you should avoid to define new builtins when possible. 279 | additional-builtins= 280 | 281 | # List of strings which can identify a callback function by name. A callback 282 | # name must start or end with one of those strings. 283 | callbacks=cb_,_cb 284 | 285 | # List of qualified module names which can have objects that can redefine 286 | # builtins. 287 | redefining-builtins-modules=six.moves,future.builtins 288 | 289 | 290 | [CLASSES] 291 | 292 | # List of method names used to declare (i.e. assign) instance attributes. 293 | defining-attr-methods=__init__,__new__,setUp 294 | 295 | # List of valid names for the first argument in a class method. 296 | valid-classmethod-first-arg=cls 297 | 298 | # List of valid names for the first argument in a metaclass class method. 299 | valid-metaclass-classmethod-first-arg=mcs 300 | 301 | # List of member names, which should be excluded from the protected access 302 | # warning. 303 | exclude-protected=_asdict,_fields,_replace,_source,_make 304 | 305 | 306 | [DESIGN] 307 | 308 | # Maximum number of arguments for function / method 309 | max-args=10 310 | 311 | # Argument names that match this expression will be ignored. Default to name 312 | # with leading underscore 313 | ignored-argument-names=_.* 314 | 315 | # Maximum number of locals for function / method body 316 | max-locals=30 317 | 318 | # Maximum number of return / yield for function / method body 319 | max-returns=6 320 | 321 | # Maximum number of branch for function / method body 322 | max-branches=12 323 | 324 | # Maximum number of statements in function / method body 325 | max-statements=100 326 | 327 | # Maximum number of parents for a class (see R0901). 328 | max-parents=7 329 | 330 | # Maximum number of attributes for a class (see R0902). 331 | max-attributes=10 332 | 333 | # Minimum number of public methods for a class (see R0903). 334 | min-public-methods=0 335 | 336 | # Maximum number of public methods for a class (see R0904). 337 | max-public-methods=20 338 | 339 | # Maximum number of boolean expressions in a if statement 340 | max-bool-expr=5 341 | 342 | # Maximum cyclomatic complexity of the code 343 | max-complexity=10 344 | 345 | 346 | [IMPORTS] 347 | 348 | # Deprecated modules which should not be used, separated by a comma 349 | deprecated-modules=optparse 350 | 351 | # Create a graph of every (i.e. internal and external) dependencies in the 352 | # given file (report RP0402 must not be disabled) 353 | import-graph= 354 | 355 | # Create a graph of external dependencies in the given file (report RP0402 must 356 | # not be disabled) 357 | ext-import-graph= 358 | 359 | # Create a graph of internal dependencies in the given file (report RP0402 must 360 | # not be disabled) 361 | int-import-graph= 362 | 363 | # Force import order to recognize a module as part of the standard 364 | # compatibility libraries. 365 | known-standard-library= 366 | 367 | # Force import order to recognize a module as part of a third party library. 368 | known-third-party=enchant 369 | 370 | # Analyse import fallback blocks. This can be used to support both Python 2 and 371 | # 3 compatible code, which means that the block might have code that exists 372 | # only in one or another interpreter, leading to false positives when analysed. 373 | analyse-fallback-blocks=no 374 | 375 | 376 | [EXCEPTIONS] 377 | 378 | # Exceptions that will emit a warning when being caught. Defaults to 379 | # "Exception" 380 | overgeneral-exceptions=builtins.Exception 381 | -------------------------------------------------------------------------------- /tests/.pylintrc: -------------------------------------------------------------------------------- 1 | # Pylint rules specifically for tests. 2 | # This is a duplicate of the root .pylintrc file, with some rule changes to accommodate test code. 3 | 4 | 5 | [MASTER] 6 | 7 | # Specify a configuration file. 8 | #rcfile= 9 | 10 | # A comma-separated list of package or module names from where C extensions may 11 | # be loaded. Extensions are loading into the active Python interpreter and may 12 | # run arbitrary code 13 | extension-pkg-whitelist=pydantic,ujson,Stemmer 14 | 15 | # Add files or directories to the blacklist. They should be base names, not 16 | # paths. 17 | ignore=CVS 18 | 19 | # Add files or directories matching the regex patterns to the blacklist. The 20 | # regex matches against base names, not paths. 21 | ignore-patterns= 22 | 23 | # Python code to execute, usually for sys.path manipulation such as 24 | # pygtk.require(). 25 | #init-hook= 26 | 27 | # Use multiple processes to speed up Pylint. 28 | jobs=1 29 | 30 | # List of plugins (as comma separated values of python modules names) to load, 31 | # usually to register additional checkers. 32 | load-plugins=pylint.extensions.docparams,pylint.extensions.mccabe 33 | 34 | # Pickle collected data for later comparisons. 35 | persistent=yes 36 | 37 | # When enabled, pylint would attempt to guess common misconfiguration and emit 38 | # user-friendly hints instead of false-positive error messages 39 | suggestion-mode=yes 40 | 41 | # Allow loading of arbitrary C extensions. Extensions are imported into the 42 | # active Python interpreter and may run arbitrary code. 43 | unsafe-load-any-extension=no 44 | 45 | 46 | [MESSAGES CONTROL] 47 | 48 | # Only show warnings with the listed confidence levels. Leave empty to show 49 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 50 | confidence= 51 | 52 | # Disable the message, report, category or checker with the given id(s). You 53 | # can either give multiple identifiers separated by comma (,) or put this 54 | # option multiple times (only on the command line, not in the configuration 55 | # file where it should appear only once).You can also use "--disable=all" to 56 | # disable everything first and then reenable specific checks. For example, if 57 | # you want to run only the similarities checker, you can use "--disable=all 58 | # --enable=similarities". If you want to run only the classes checker, but have 59 | # no Warning level messages displayed, use"--disable=all --enable=classes 60 | # --disable=W" 61 | disable=arguments-differ, 62 | duplicate-code, 63 | inconsistent-return-statements, 64 | locally-disabled, 65 | missing-class-docstring, 66 | missing-docstring, 67 | missing-function-docstring, 68 | missing-raises-doc, 69 | missing-return-doc, 70 | protected-access, 71 | redefined-outer-name, 72 | suppressed-message, 73 | too-many-instance-attributes, 74 | useless-suppression, 75 | logging-fstring-interpolation, 76 | 77 | # Enable the message, report, category or checker with the given id(s). You can 78 | # either give multiple identifier separated by comma (,) or put this option 79 | # multiple time (only on the command line, not in the configuration file where 80 | # it should appear only once). See also the "--disable" option for examples. 81 | enable=c-extension-no-member 82 | 83 | 84 | [REPORTS] 85 | 86 | # Python expression which should return a note less than 10 (10 is the highest 87 | # note). You have access to the variables errors warning, statement which 88 | # respectively contain the number of errors / warnings messages and the total 89 | # number of statements analyzed. This is used by the global evaluation report 90 | # (RP0004). 91 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 92 | 93 | # Template used to display messages. This is a python new-style format string 94 | # used to format the message information. See doc for all details 95 | msg-template={path}:{line}:{column} : {msg} ({symbol}, {msg_id}) 96 | 97 | # Set the output format. Available formats are text, parseable, colorized, msvs 98 | # (visual studio) and html. You can also give a reporter class, eg 99 | # mypackage.mymodule.MyReporterClass. 100 | output-format=colorized 101 | 102 | # Tells whether to display a full report or only the messages 103 | reports=no 104 | 105 | # Activate the evaluation score. 106 | score=yes 107 | 108 | 109 | [REFACTORING] 110 | 111 | # Maximum number of nested blocks for function / method body 112 | max-nested-blocks=5 113 | 114 | # Complete name of functions that never returns. When checking for 115 | # inconsistent-return-statements if a never returning function is called then 116 | # it will be considered as an explicit return statement and no message will be 117 | # printed. 118 | never-returning-functions=optparse.Values,sys.exit 119 | 120 | 121 | [LOGGING] 122 | 123 | # Logging modules to check that the string format arguments are in logging 124 | # function parameter format 125 | logging-modules=logging 126 | 127 | 128 | [SPELLING] 129 | 130 | # Limits count of emitted suggestions for spelling mistakes 131 | max-spelling-suggestions=4 132 | 133 | # Spelling dictionary name. Available dictionaries: none. To make it working 134 | # install python-enchant package. 135 | spelling-dict= 136 | 137 | # List of comma separated words that should not be checked. 138 | spelling-ignore-words= 139 | 140 | # A path to a file that contains private dictionary; one word per line. 141 | spelling-private-dict-file= 142 | 143 | # Tells whether to store unknown words to indicated private dictionary in 144 | # --spelling-private-dict-file option instead of raising a message. 145 | spelling-store-unknown-words=no 146 | 147 | 148 | [MISCELLANEOUS] 149 | 150 | # List of note tags to take in consideration, separated by a comma. 151 | notes=FIXME, 152 | XXX 153 | 154 | 155 | [SIMILARITIES] 156 | 157 | # Ignore comments when computing similarities. 158 | ignore-comments=yes 159 | 160 | # Ignore docstrings when computing similarities. 161 | ignore-docstrings=yes 162 | 163 | # Ignore imports when computing similarities. 164 | ignore-imports=no 165 | 166 | # Minimum lines number of a similarity. 167 | min-similarity-lines=4 168 | 169 | 170 | [TYPECHECK] 171 | 172 | # List of decorators that produce context managers, such as 173 | # contextlib.contextmanager. Add to this list to register other decorators that 174 | # produce valid context managers. 175 | contextmanager-decorators=contextlib.contextmanager 176 | 177 | # List of members which are set dynamically and missed by pylint inference 178 | # system, and so shouldn't trigger E1101 when accessed. Python regular 179 | # expressions are accepted. 180 | generated-members= 181 | 182 | # Tells whether missing members accessed in mixin class should be ignored. A 183 | # mixin class is detected if its name ends with "mixin" (case insensitive). 184 | ignore-mixin-members=yes 185 | 186 | # This flag controls whether pylint should warn about no-member and similar 187 | # checks whenever an opaque object is returned when inferring. The inference 188 | # can return multiple potential results while evaluating a Python object, but 189 | # some branches might not be evaluated, which results in partial inference. In 190 | # that case, it might be useful to still emit no-member and other checks for 191 | # the rest of the inferred objects. 192 | ignore-on-opaque-inference=yes 193 | 194 | # List of class names for which member attributes should not be checked (useful 195 | # for classes with dynamically set attributes). This supports the use of 196 | # qualified names. 197 | ignored-classes=optparse.Values,thread._local,_thread._local 198 | 199 | # List of module names for which member attributes should not be checked 200 | # (useful for modules/projects where namespaces are manipulated during runtime 201 | # and thus existing member attributes cannot be deduced by static analysis. It 202 | # supports qualified module names, as well as Unix pattern matching. 203 | ignored-modules= 204 | 205 | # Show a hint with possible names when a member name was not found. The aspect 206 | # of finding the hint is based on edit distance. 207 | missing-member-hint=yes 208 | 209 | # The minimum edit distance a name should have in order to be considered a 210 | # similar match for a missing member name. 211 | missing-member-hint-distance=1 212 | 213 | # The total number of similar names that should be taken in consideration when 214 | # showing a hint for a missing member. 215 | missing-member-max-choices=1 216 | 217 | 218 | [VARIABLES] 219 | 220 | # List of additional names supposed to be defined in builtins. Remember that 221 | # you should avoid to define new builtins when possible. 222 | additional-builtins= 223 | 224 | # Tells whether unused global variables should be treated as a violation. 225 | allow-global-unused-variables=yes 226 | 227 | # List of strings which can identify a callback function by name. A callback 228 | # name must start or end with one of those strings. 229 | callbacks=cb_, 230 | _cb 231 | 232 | # A regular expression matching the name of dummy variables (i.e. expectedly 233 | # not used). 234 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^unused_ 235 | 236 | # Argument names that match this expression will be ignored. Default to name 237 | # with leading underscore 238 | ignored-argument-names=_.*|^unused_ 239 | 240 | # Tells whether we should check for unused import in __init__ files. 241 | init-import=no 242 | 243 | # List of qualified module names which can have objects that can redefine 244 | # builtins. 245 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,io,builtins 246 | 247 | 248 | [FORMAT] 249 | 250 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 251 | expected-line-ending-format= 252 | 253 | # Regexp for a line that is allowed to be longer than the limit. 254 | ignore-long-lines=^\s*(# )??$ 255 | 256 | # Number of spaces of indent required inside a hanging or continued line. 257 | indent-after-paren=4 258 | 259 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 260 | # tab). 261 | indent-string=' ' 262 | 263 | # Maximum number of characters on a single line. 264 | max-line-length=120 265 | 266 | # Maximum number of lines in a module 267 | max-module-lines=1000 268 | 269 | # Allow the body of a class to be on the same line as the declaration if body 270 | # contains single statement. 271 | single-line-class-stmt=no 272 | 273 | # Allow the body of an if to be on the same line as the test if there is no 274 | # else. 275 | single-line-if-stmt=no 276 | 277 | 278 | [BASIC] 279 | 280 | # Naming style matching correct argument names 281 | argument-naming-style=snake_case 282 | 283 | # Regular expression matching correct argument names. Overrides argument- 284 | # naming-style 285 | #argument-rgx=[a-z_][a-z0-9_]{2,35}$ 286 | 287 | # Naming style matching correct attribute names 288 | attr-naming-style=snake_case 289 | 290 | # Regular expression matching correct attribute names. Overrides attribute- 291 | # naming-style 292 | #attr-rgx=[a-z_][a-z0-9_]{2,35}$ 293 | 294 | # Bad variable names which should always be refused, separated by a comma 295 | bad-names=foo, 296 | bar, 297 | baz, 298 | toto, 299 | tutu, 300 | tata 301 | 302 | # Naming style matching correct class attribute names 303 | #class-attribute-naming-style=any 304 | 305 | # Regular expression matching correct class attribute names 306 | # This allows `snake_case` XOR `UPPER_CASE` 307 | class-attribute-rgx=(([a-z_][a-z0-9_]+)|([A-Z_][A-Z0-9_]+)|(__.*__))$ 308 | 309 | # Naming style matching correct class names 310 | class-naming-style=PascalCase 311 | 312 | # Regular expression matching correct class names. Overrides class-naming-style 313 | #class-rgx=[A-Z_][a-zA-Z0-9]+$ 314 | 315 | # Naming style matching correct constant names 316 | const-naming-style=UPPER_CASE 317 | 318 | # Regular expression matching correct constant names. Overrides const-naming- 319 | # style 320 | #const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 321 | 322 | # Minimum line length for functions/classes that require docstrings, shorter 323 | # ones are exempt. 324 | docstring-min-length=-1 325 | 326 | # Naming style matching correct function names 327 | function-naming-style=snake_case 328 | 329 | # Regular expression matching correct function names. Overrides function- 330 | # naming-style 331 | #function-rgx=[a-z_][a-z0-9_]+$ 332 | 333 | # Good variable names which should always be accepted, separated by a comma 334 | good-names=i, 335 | j, 336 | k, 337 | ex, 338 | Run, 339 | _ 340 | 341 | # Include a hint for the correct naming format with invalid-name 342 | include-naming-hint=no 343 | 344 | # Naming style matching correct inline iteration names 345 | #inlinevar-naming-style=any 346 | 347 | # Regular expression matching correct inline iteration names. Overrides 348 | # inlinevar-naming-style 349 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 350 | 351 | # Naming style matching correct method names 352 | method-naming-style=snake_case 353 | 354 | # Regular expression matching correct method names. Overrides method-naming- 355 | # style 356 | #method-rgx=[a-z_][a-z0-9_]+$ 357 | 358 | # Naming style matching correct module names 359 | module-naming-style=snake_case 360 | 361 | # Regular expression matching correct module names 362 | #module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 363 | 364 | # Colon-delimited sets of names that determine each other's naming style when 365 | # the name regexes allow several styles. 366 | name-group= 367 | 368 | # Regular expression which should only match function or class names that do 369 | # not require a docstring. 370 | no-docstring-rgx=^_ 371 | 372 | # List of decorators that produce properties, such as abc.abstractproperty. Add 373 | # to this list to register other decorators that produce valid properties. 374 | property-classes=abc.abstractproperty 375 | 376 | # Naming style matching correct variable names 377 | variable-naming-style=snake_case 378 | 379 | # Regular expression matching correct variable names. Overrides variable- 380 | # naming-style 381 | #variable-rgx=[a-z_][a-z0-9_]+$ 382 | 383 | # Docstring-vs-method-signature inspection. See `pylint.extensions.docparams` plugin. Note 384 | # that we also disable "missing-raises-doc" in `disables=` 385 | accept-no-param-doc=yes 386 | accept-no-raise-doc=yes 387 | accept-no-return-doc=yes 388 | accept-no-yields-doc=yes 389 | default-docstring-type=google 390 | 391 | 392 | [DESIGN] 393 | 394 | # Maximum number of arguments for function / method 395 | max-args=99 396 | 397 | # Maximum number of attributes for a class (see R0902). 398 | max-attributes=99 399 | 400 | # Maximum number of boolean expressions in a if statement 401 | max-bool-expr=99 402 | 403 | # Maximum number of branch for function / method body 404 | max-branches=99 405 | 406 | # Maximum number of locals for function / method body 407 | max-locals=99 408 | 409 | # Maximum number of parents for a class (see R0901). 410 | max-parents=99 411 | 412 | # Maximum number of public method for a class (see R0904). 413 | max-public-methods=99 414 | 415 | # Maximum number of return / yield for function / method body 416 | max-returns=99 417 | 418 | # Maximum number of statements in function / method body 419 | max-statements=99 420 | 421 | # Minimum number of public method for a class (see R0903). 422 | min-public-methods=0 423 | 424 | # Maximum cyclomatic complexity of the code 425 | max-complexity=10 426 | 427 | [CLASSES] 428 | 429 | # List of method names used to declare (i.e. assign) instance attributes. 430 | defining-attr-methods=__init__, 431 | __new__, 432 | setUp 433 | 434 | # List of member names, which should be excluded from the protected access 435 | # warning. 436 | exclude-protected=_asdict, 437 | _fields, 438 | _replace, 439 | _source, 440 | _make 441 | 442 | # List of valid names for the first argument in a class method. 443 | valid-classmethod-first-arg=cls 444 | 445 | # List of valid names for the first argument in a metaclass class method. 446 | valid-metaclass-classmethod-first-arg=mcs 447 | 448 | 449 | [IMPORTS] 450 | 451 | # Allow wildcard imports from modules that define __all__. 452 | allow-wildcard-with-all=no 453 | 454 | # Analyse import fallback blocks. This can be used to support both Python 2 and 455 | # 3 compatible code, which means that the block might have code that exists 456 | # only in one or another interpreter, leading to false positives when analysed. 457 | analyse-fallback-blocks=no 458 | 459 | # Deprecated modules which should not be used, separated by a comma 460 | deprecated-modules=regsub, 461 | TERMIOS, 462 | Bastion, 463 | rexec, 464 | optparse 465 | 466 | # Create a graph of external dependencies in the given file (report RP0402 must 467 | # not be disabled) 468 | ext-import-graph= 469 | 470 | # Create a graph of every (i.e. internal and external) dependencies in the 471 | # given file (report RP0402 must not be disabled) 472 | import-graph= 473 | 474 | # Create a graph of internal dependencies in the given file (report RP0402 must 475 | # not be disabled) 476 | int-import-graph= 477 | 478 | # Force import order to recognize a module as part of the standard 479 | # compatibility libraries. 480 | known-standard-library= 481 | 482 | # Force import order to recognize a module as part of a third party library. 483 | known-third-party=enchant 484 | 485 | 486 | [EXCEPTIONS] 487 | 488 | # Exceptions that will emit a warning when being caught. Defaults to 489 | # "Exception" 490 | overgeneral-exceptions=builtins.Exception 491 | --------------------------------------------------------------------------------