18 Commits 35fdc2772b ... 818dcf2d0a

Author SHA1 Message Date
  Fabian Peter Hammerle 818dcf2d0a _write_event: added missing unit test for temp file cleanup 7 months ago
  Fabian Peter Hammerle 6a24a4759c only write to disk when creating or updating item 7 months ago
  Fabian Peter Hammerle 6a1a39036e cleanup temporary files 7 months ago
  Fabian Peter Hammerle 5813a17fce added changelog 7 months ago
  Fabian Peter Hammerle 4932da926c readme: added coverage badge 7 months ago
  Fabian Peter Hammerle d33bf43f83 ci/coveralls: add lower version constraint 7 months ago
  Fabian Peter Hammerle e425e40c93 ci/coveralls: fix secret name 7 months ago
  Fabian Peter Hammerle 8c7c6b37ef ci/coveralls: replace github-action with python package 7 months ago
  Fabian Peter Hammerle d1d62887b0 ci: report coverage to coveralls.io 7 months ago
  Fabian Peter Hammerle 2f25f033e2 readme: added python versions badge 7 months ago
  Fabian Peter Hammerle 0b6263990e ci: fix typo in comment 7 months ago
  Fabian Peter Hammerle 4352da062f ci: workaround false positive parse error 7 months ago
  Fabian Peter Hammerle 4ede518438 ci: lint tests 7 months ago
  Fabian Peter Hammerle bdd78839d2 ci: added black format check 7 months ago
  Fabian Peter Hammerle 116a5414b7 setup.py/classifiers: specify minor python versions (tested in CI pipeline) 7 months ago
  Fabian Peter Hammerle 460a0de9be readme: added pipeline badge 7 months ago
  Fabian Peter Hammerle c945fb74f2 ci: workaround astroid triggering `No module named 'importlib_metadata'` 7 months ago
  Fabian Peter Hammerle a38c17c234 setup github workflow 7 months ago
6 changed files with 115 additions and 17 deletions
  1. 50 0
      .github/workflows/python.yml
  2. 18 0
      CHANGELOG.md
  3. 3 0
      README.md
  4. 18 8
      ical2vdir/__init__.py
  5. 6 1
      setup.py
  6. 20 8
      tests/vdir_test.py

+ 50 - 0
.github/workflows/python.yml

@@ -0,0 +1,50 @@
+# shown in badge
+# https://help.github.com/en/actions/automating-your-workflow-with-github-actions/configuring-a-workflow#adding-a-workflow-status-badge-to-your-repository
+name: tests
+
+on:
+  push:
+  pull_request:
+  schedule:
+  - cron: '0 20 * * 5'
+
+jobs:
+  build:
+    runs-on: ubuntu-18.04
+    strategy:
+      matrix:
+        python-version:
+        - 3.5
+        - 3.6
+        - 3.7
+        - 3.8
+    steps:
+    - uses: actions/checkout@v1
+    - uses: actions/setup-python@v1
+      with:
+        python-version: ${{ matrix.python-version }}
+    - run: pip install --upgrade pipenv>=2018.10.9
+    - run: pipenv sync --dev
+    # ModuleNotFoundError: No module named 'importlib_metadata'
+    - run: if python3 -c 'import sys; sys.exit(sys.version_info < (3, 8))'; then
+           pipenv graph;
+           pipenv install --dev importlib-metadata;
+           fi
+    - run: pipenv graph
+    - run: pipenv run pylint ical2vdir
+    # https://github.com/PyCQA/pylint/issues/352
+    # disable parse-error due to:
+    # > tests/resources/__init__.py:1:0: F0010: error while code parsing: Unable to load file tests/resources/__init__.py:
+    # > [Errno 2] No such file or directory: 'tests/resources/__init__.py' (parse-error)
+    - run: pipenv run pylint --disable=missing-requirement --disable=parse-error tests/*
+    - run: pipenv run pytest --cov=ical2vdir --cov-report=term-missing --cov-fail-under=100
+    - run: pipenv run black --check .
+    # >=1.9.0 to detect branch name
+    # https://github.com/coveralls-clients/coveralls-python/pull/207
+    # https://github.com/coverallsapp/github-action/issues/4#issuecomment-547036866
+    - run: pip install 'coveralls>=1.9.0,<2'
+    # https://github.com/coverallsapp/github-action/issues/30
+    # https://github.com/coverallsapp/github-action/issues/4#issuecomment-529399410
+    - run: coveralls
+      env:
+        COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}

+ 18 - 0
CHANGELOG.md

@@ -0,0 +1,18 @@
+# Changelog
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+## [0.1.1] - 2020-02-06
+### Fixed
+- only write to disk when creating or updating item
+- cleanup temporary files
+
+## [0.1.0] - 2020-02-06
+
+[Unreleased]: https://github.com/fphammerle/ical2vdir/compare/v0.1.1...HEAD
+[0.1.1]: https://github.com/fphammerle/ical2vdir/compare/v0.1.0...v0.1.1
+[0.1.0]: https://github.com/fphammerle/ical2vdir/releases/tag/v0.1.0

+ 3 - 0
README.md

@@ -1,7 +1,10 @@
 # ical2vdir 📅
 
 [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
+[![CI Pipeline Status](https://github.com/fphammerle/ical2vdir/workflows/tests/badge.svg)](https://github.com/fphammerle/ical2vdir/actions)
+[![Coverage Status](https://coveralls.io/repos/github/fphammerle/ical2vdir/badge.svg?branch=master)](https://coveralls.io/github/fphammerle/ical2vdir?branch=master)
 [![Last Release](https://img.shields.io/pypi/v/ical2vdir.svg)](https://pypi.org/project/ical2vdir/#history)
+[![Compatible Python Versions](https://img.shields.io/pypi/pyversions/ical2vdir.svg)](https://pypi.org/project/ical2vdir/)
 
 Convert / split single [iCalendar](https://en.wikipedia.org/wiki/ICalendar)
 `.ics` file into a

+ 18 - 8
ical2vdir/__init__.py

@@ -86,18 +86,28 @@ def _event_vdir_filename(event: icalendar.cal.Event) -> str:
     return output_filename + _VDIR_EVENT_FILE_EXTENSION
 
 
-def _export_event(
-    event: icalendar.cal.Event, output_dir_path: pathlib.Path
-) -> pathlib.Path:
+def _write_event(event: icalendar.cal.Event, path: pathlib.Path):
+    # > Creating and modifying items or metadata files should happen atomically.
+    # https://vdirsyncer.readthedocs.io/en/stable/vdir.html#writing-to-vdirs
     temp_fd, temp_path = tempfile.mkstemp(
         prefix="ical2vdir-", suffix=_VDIR_EVENT_FILE_EXTENSION
     )
-    os.write(temp_fd, event.to_ical())
-    os.close(temp_fd)
+    try:
+        os.write(temp_fd, event.to_ical())
+        os.close(temp_fd)
+        os.rename(temp_path, path)
+    finally:
+        if os.path.exists(temp_path):
+            os.unlink(temp_path)
+
+
+def _sync_event(
+    event: icalendar.cal.Event, output_dir_path: pathlib.Path
+) -> pathlib.Path:
     output_path = output_dir_path.joinpath(_event_vdir_filename(event))
     if not output_path.exists():
         _LOGGER.info("creating %s", output_path)
-        os.rename(temp_path, output_path)
+        _write_event(event, output_path)
     else:
         with open(output_path, "rb") as current_file:
             current_event = icalendar.Event.from_ical(current_file.read())
@@ -105,7 +115,7 @@ def _export_event(
             _LOGGER.debug("%s is up to date", output_path)
         else:
             _LOGGER.info("updating %s", output_path)
-            os.rename(temp_path, output_path)
+            _write_event(event, output_path)
     return output_path
 
 
@@ -161,7 +171,7 @@ def _main():
     for component in calendar.subcomponents:
         if isinstance(component, icalendar.cal.Event):
             extra_paths.discard(
-                _export_event(event=component, output_dir_path=args.output_dir_path)
+                _sync_event(event=component, output_dir_path=args.output_dir_path)
             )
         else:
             _LOGGER.debug("%s", component)

+ 6 - 1
setup.py

@@ -32,12 +32,17 @@ setuptools.setup(
     license="GPLv3+",
     keywords=["calendar", "event", "iCal", "iCalendar", "ics", "split", "sync", "vdir"],
     classifiers=[
+        # https://pypi.org/classifiers/
         "Development Status :: 3 - Alpha",
         "Intended Audience :: End Users/Desktop",
         "Intended Audience :: System Administrators",
         "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
         "Operating System :: OS Independent",
-        "Programming Language :: Python :: 3",
+        # .github/workflows/python.yml
+        "Programming Language :: Python :: 3.5",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
         "Topic :: Utilities",
     ],
     entry_points={"console_scripts": ["ical2vdir = ical2vdir:_main",]},

+ 20 - 8
tests/vdir_test.py

@@ -16,7 +16,9 @@
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
 import copy
+import os
 import pathlib
+import unittest.mock
 
 import icalendar.cal
 import pytest
@@ -49,6 +51,16 @@ END:VEVENT
 # pylint: disable=protected-access
 
 
+def test__write_event_cleanup(tmpdir):
+    event = icalendar.cal.Event.from_ical(_SINGLE_EVENT_ICAL)
+    with unittest.mock.patch("os.unlink") as unlink_mock:
+        with pytest.raises(IsADirectoryError):
+            ical2vdir._write_event(event, pathlib.Path(tmpdir))
+    unlink_mock.assert_called_once()
+    unlink_args, _ = unlink_mock.call_args
+    os.unlink(unlink_args[0])
+
+
 @pytest.mark.parametrize(
     ("event_ical", "expected_filename"),
     [
@@ -80,22 +92,22 @@ def test__event_vdir_filename(event_ical, expected_filename):
 
 
 @pytest.mark.parametrize("event_ical", [_SINGLE_EVENT_ICAL])
-def test__export_event_create(tmpdir, event_ical):
+def test__sync_event_create(tmpdir, event_ical):
     temp_path = pathlib.Path(tmpdir)
     event = icalendar.cal.Event.from_ical(event_ical)
-    ical2vdir._export_event(event, temp_path)
+    ical2vdir._sync_event(event, temp_path)
     (ics_path,) = temp_path.iterdir()
     assert ics_path.name == "1qa2ws3ed4rf5tg@google.com.ics"
     assert ics_path.read_bytes() == _SINGLE_EVENT_ICAL
 
 
 @pytest.mark.parametrize("event_ical", [_SINGLE_EVENT_ICAL])
-def test__export_event_update(tmpdir, event_ical):
+def test__sync_event_update(tmpdir, event_ical):
     temp_path = pathlib.Path(tmpdir)
     event = icalendar.cal.Event.from_ical(event_ical)
-    ical2vdir._export_event(event, temp_path)
+    ical2vdir._sync_event(event, temp_path)
     event["SUMMARY"] += " suffix"
-    ical2vdir._export_event(event, temp_path)
+    ical2vdir._sync_event(event, temp_path)
     (ics_path,) = temp_path.iterdir()
     assert ics_path.name == event["UID"] + ".ics"
     assert ics_path.read_bytes() == _SINGLE_EVENT_ICAL.replace(
@@ -104,12 +116,12 @@ def test__export_event_update(tmpdir, event_ical):
 
 
 @pytest.mark.parametrize("event_ical", [_SINGLE_EVENT_ICAL])
-def test__export_event_unchanged(tmpdir, event_ical):
+def test__sync_event_unchanged(tmpdir, event_ical):
     temp_path = pathlib.Path(tmpdir)
     event = icalendar.cal.Event.from_ical(event_ical)
-    ical2vdir._export_event(event, temp_path)
+    ical2vdir._sync_event(event, temp_path)
     (ics_path,) = temp_path.iterdir()
     old_stat = copy.deepcopy(ics_path.stat())
-    ical2vdir._export_event(event, temp_path)
+    ical2vdir._sync_event(event, temp_path)
     assert ics_path.stat() == old_stat
     assert ics_path.read_bytes() == _SINGLE_EVENT_ICAL